/data/`, renders a textfmt + HTML report, and gates against the per-suite threshold in `.testcoverage.yml`. `make cov` merges whichever suites have run and gates against the total.
@@ -341,8 +356,8 @@ The primary E2E integration test suite lives in `tests/e2e/sdk/`. It uses the Ty
**Running E2E tests**:
```bash
-make test-e2e # Build the cover binary, install deps, run all E2E tests
-KEEP_RUNNING=true make test-e2e # Don't tear down services after tests
+# Build the cover binary, install deps, run all E2E tests
+make test-e2e
```
`make test-e2e` builds `bin/wavehouse-cov` (coverage-instrumented) and runs the orchestrator under `scripts/orchestrator/` to wire ClickHouse + the cover binary into the suite. covdata flushes on SIGINT into `tmp/coverage/e2e/data/`.
@@ -532,7 +547,9 @@ git tag sdk-v0.1.0
git push origin sdk-v0.1.0
```
-> **The first tagged release promotes `latest`.** npm sets a package's `latest` dist-tag on its *first* publish even under `--tag dev`, so until the first `sdk-v*` release a bare `npm install @wavehouse/sdk` (and the bare CDN URLs) resolve to a `0.0.0-dev.*` snapshot. The first tagged stable release moves `latest` to a real version and fixes this for every consumer.
+:::caution[The first tagged release promotes `latest`]
+npm sets a package's `latest` dist-tag on its *first* publish even under `--tag dev`, so until the first `sdk-v*` release a bare `npm install @wavehouse/sdk` (and the bare CDN URLs) resolve to a `0.0.0-dev.*` snapshot. The first tagged stable release moves `latest` to a real version and fixes this for every consumer.
+:::
## CI & review automation
diff --git a/docs/src/content/docs/getting-started.md b/docs/src/content/docs/getting-started.md
index 2a35101c..5ae0e978 100644
--- a/docs/src/content/docs/getting-started.md
+++ b/docs/src/content/docs/getting-started.md
@@ -75,7 +75,9 @@ curl -s -X POST "http://localhost:8080/v1/query?table=clicks" \
`POST /v1/query?table={table}` and `GET/POST /v1/pipes/{name}` are cached in-process (L1 Ristretto) with singleflight coalescing — duplicate concurrent queries hit ClickHouse once. For raw SQL there's `POST /v1/admin/query` (an admin escape hatch that never caches, emitting `Cache-Control: no-store`), but it's **admin-only** — the trial `public` role can't reach it. To use it, swap the public default for real auth: configure a JWT secret and present a token whose role is the policy [`admin_role`](/access-control#admin_role--the-privileged-role).
-Prefer a type-safe query builder? See the [structured query endpoint](/api#post-v1querytabletable--structured-query) or the [TypeScript SDK](/sdk).
+:::tip[Prefer a type-safe client?]
+The [TypeScript SDK](/sdk) wraps this endpoint in a chainable query builder with autocomplete on your table names and row types — plus live queries and streaming. The raw shapes are in the [structured query reference](/api#post-v1querytabletable--structured-query).
+:::
## 5. Subscribe to real-time updates
@@ -89,6 +91,16 @@ curl -N "http://localhost:8080/v1/stream?table=clicks"
curl -N "http://localhost:8080/v1/stream?table=clicks&since=2026-03-24T11:00:00Z"
```
+## Troubleshooting first runs
+
+The handful of things that most often trip up a first session — each is expected behavior with a quick fix:
+
+- **`404 unknown table: clicks` on the first ingest.** Schema discovery refreshes every 60 seconds (`WH_SCHEMA_REFRESH_INTERVAL`), so a just-created table may not be visible yet. Wait and retry — worst case the next refresh is a full 60 seconds out. (`POST /v1/schema/refresh` forces it, but that endpoint is admin-only — the trial `public` role can't call it.)
+- **The query returns `[]` right after an ingest succeeded.** Ingest acknowledges as soon as the event is durable in the WAL; the batch worker flushes to ClickHouse every few seconds. If you query within that window the rows simply aren't in ClickHouse yet — re-query after ~5 seconds. (The [SSE stream](#5-subscribe-to-real-time-updates) sees events *immediately* — it's broadcast before the flush.)
+- **`403` on a table you created yourself.** WaveHouse is fail-closed and the trial policy grants the `public` role access to the *named demo tables only* (`clicks`, `events`). A new table needs a policy entry — see [Access Control](/access-control) for granting roles per table.
+- **A port is already taken.** The stack binds `8080` (WaveHouse) and `8123`/`9000` (ClickHouse). Stop whatever holds the port or edit the `ports:` mappings in `deployments/compose/standalone.yaml`.
+- **Errors right at first boot.** `docker compose -f deployments/compose/standalone.yaml ps` should show both services up — ClickHouse takes a few seconds to initialize on a cold start, so give the stack a moment before the first request.
+
## Next steps
- **[Architecture](/architecture)** — how ingest, query, cache, and streaming fit together.
diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx
index 6cefa59a..b10e97b1 100644
--- a/docs/src/content/docs/index.mdx
+++ b/docs/src/content/docs/index.mdx
@@ -25,7 +25,7 @@ hero:
variant: minimal
---
-import { Card, CardGrid, LinkCard, LinkButton } from '@astrojs/starlight/components';
+import { Card, CardGrid, LinkCard, LinkButton, Tabs, TabItem } from '@astrojs/starlight/components';
import HomepageCtaTracking from '../../components/HomepageCtaTracking.astro';
@@ -102,6 +102,50 @@ If you're building user-facing analytics, **WaveHouse is like Supabase for Click
**Plus** — optional [deduplication](/configuration#deduplication) (idempotent ingest by ID), a dead-letter queue for failed batch inserts, and Tinybird-style [named pipes](/pipes) with parameter binding and per-role restrictions.
+## Query it like a database. Subscribe to it like a socket.
+
+The zero-dependency [TypeScript SDK](/sdk) wraps the whole surface — typed inserts, a chainable query builder, and live queries that backfill history before streaming:
+
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({ baseURL: 'https://wavehouse.example.com' });
+
+ // Returns immediately — buffered in the WAL, schema-validated
+ // at the edge, batch-flushed to ClickHouse behind the scenes.
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+ ```
+
+
+ ```ts
+ // Chainable, type-safe, never throws — branch on { data, error }.
+ const { data, error } = await wh.from('clicks')
+ .select('page', 'button', 'score')
+ .where('page', '=', '/home')
+ .orderBy('received_timestamp', 'desc')
+ .limit(100);
+ ```
+
+
+ ```ts
+ // Historical backfill + real-time stream, deduplicated at the seam.
+ const lq = wh.from('clicks')
+ .selectAll()
+ .where('page', '=', '/home')
+ .orderBy('received_timestamp', 'desc')
+ .limit(100)
+ .liveQuery({
+ initial: (result) => setRows(result.data ?? []),
+ next: (event) => addRow(event.data),
+ });
+
+ // later: lq.close();
+ ```
+
+
+
## Up in five minutes
One compose file — ClickHouse + WaveHouse — and you have an ingest endpoint, a query endpoint, and a live event stream:
@@ -123,11 +167,7 @@ curl -N "http://localhost:8080/v1/stream?table=clicks"
WaveHouse is fail-closed, so the standalone stack ships a permissive trial policy (a non-admin `public` role that can read/write the demo tables, seeded on first boot) — then it's five minutes from `git clone` to a live event stream. See [Getting Started](/getting-started).
-## How it ships
-
-
- One `wavehouse` process — API, batch worker, embedded NATS JetStream, and optional embedded Pebble dedup, all in-process. **The only external dependency is ClickHouse.** Same binary on a laptop, in Docker, or on a production host.
-
+**How it ships:** one `wavehouse` process — API, batch worker, embedded NATS JetStream, and optional embedded Pebble dedup, all in-process. **The only external dependency is ClickHouse.** Same binary on a laptop, in Docker, or on a production host.
## Where to next
diff --git a/docs/src/content/docs/ingest-pipeline.md b/docs/src/content/docs/ingest-pipeline.md
index 24f0b367..ac2809ac 100644
--- a/docs/src/content/docs/ingest-pipeline.md
+++ b/docs/src/content/docs/ingest-pipeline.md
@@ -135,15 +135,15 @@ the moment the slot frees.
```mermaid
stateDiagram-v2
[*] --> Idle
- Idle --> Accumulating: add first row (arm timer)
- Accumulating --> Accumulating: add (batch under maxBatch)
- Accumulating --> Flushing: size hit or timer fires (start flush)
- Flushing --> Flushing: add (accumulate a fresh batch)
- Flushing --> FlushingQueued: size hit or timer fires (latch flushQueued)
+ Idle --> Accumulating: first row (arm timer)
+ Accumulating --> Accumulating: add (under maxBatch)
+ Accumulating --> Flushing: size or timer — flush
+ Flushing --> Flushing: add (fresh batch)
+ Flushing --> FlushingQueued: size or timer — latch
FlushingQueued --> FlushingQueued: add
- Flushing --> Accumulating: flush done, rows remain (wait for own trigger)
- Flushing --> Idle: flush done, batch empty
- FlushingQueued --> Flushing: flush done, start the deferred flush
+ Flushing --> Accumulating: done — rows remain
+ Flushing --> Idle: done — empty
+ FlushingQueued --> Flushing: done — deferred flush
```
Two consequences worth internalizing:
diff --git a/docs/src/content/docs/pipes.md b/docs/src/content/docs/pipes.mdx
similarity index 90%
rename from docs/src/content/docs/pipes.md
rename to docs/src/content/docs/pipes.mdx
index de58f264..f640414f 100644
--- a/docs/src/content/docs/pipes.md
+++ b/docs/src/content/docs/pipes.mdx
@@ -5,6 +5,8 @@ sidebar:
order: 6
---
+import { Tabs, TabItem } from "@astrojs/starlight/components";
+
A **named pipe** is a saved SQL query, registered under a name, that callers run by name with parameters — without ever sending raw SQL. They turn an ad-hoc query into a stable, cached, access-controlled endpoint: you write the SQL once as an operator, expose it at `GET/POST /v1/pipes/{name}`, and clients supply only the declared parameters.
Pipes are the right tool when a query is reusable and shouldn't live in client code — dashboards, reports, public APIs over curated slices of data. They sit on the **cached read path** (shared L1 + singleflight, same as structured queries), and authorize through a simple per-pipe allowlist rather than the full [policy engine](/access-control).
@@ -33,7 +35,9 @@ A pipe is defined by five fields:
}
```
-> **Pipe SQL is operator-authored and admin-only to write.** Anyone who can create or edit a pipe can run arbitrary SQL, so pipe management lives entirely behind the admin gate. Callers of a pipe never send SQL — only parameter values. Keep that boundary in mind when you design a pipe: the SQL is your trusted code, the parameters are untrusted input.
+:::caution[Pipe SQL is operator-authored and admin-only to write]
+Anyone who can create or edit a pipe can run arbitrary SQL, so pipe management lives entirely behind the admin gate. Callers of a pipe never send SQL — only parameter values. Keep that boundary in mind when you design a pipe: the SQL is your trusted code, the parameters are untrusted input.
+:::
## Parameters
@@ -157,20 +161,30 @@ The response is a JSON array of rows. Results flow through the shared in-process
## Bootstrapping from `.sql` files
-For version-controlled, deploy-time pipe definitions, point `pipes.dir` at a directory of `.sql` files:
+For version-controlled, deploy-time pipe definitions, point `pipes.dir` (in `config.yaml`, or its environment twin) at a directory of `.sql` files:
+
+
```yaml
-# config.yaml
pipes:
dir: /app/pipes # seed .sql files into KV on startup
```
+
+
+```ini
+WH_PIPES_DIR=/app/pipes
+```
+
+
On startup, each top-level `*.sql` file is loaded as a pipe whose **name is the filename** (without the extension) and whose **SQL is the file contents**. So `pipes/top_pages.sql` becomes the pipe `top_pages`. The directory is a one-time **seed**, not authoritative storage:
- It will **not overwrite** a pipe that already exists in KV — once a pipe is in the store (seeded earlier, or created via the API), the file is skipped.
- After bootstrap, the API and KV are the source of truth; the directory is read-only at runtime. Mount it read-only in containers (e.g. `./my-pipes:/app/pipes:ro`).
-> **Bootstrapped pipes are admin-only until you grant roles.** A `.sql` file carries only the SQL — there's no way to express `allowed_roles`, `parameters`, or a `description` in the file. A pipe seeded this way therefore has an empty `allowed_roles`, which (fails-closed) means **admin only**. To expose it to other roles or add formal parameters, `PUT` the full definition through `/v1/admin/pipes/{name}` after boot. Inline `{{name:default}}` placeholders still work in a bootstrapped file, so the SQL can be parameter-ready even without formal declarations.
+:::caution[Bootstrapped pipes are admin-only until you grant roles]
+A `.sql` file carries only the SQL — there's no way to express `allowed_roles`, `parameters`, or a `description` in the file. A pipe seeded this way therefore has an empty `allowed_roles`, which (fails-closed) means **admin only**. To expose it to other roles or add formal parameters, `PUT` the full definition through `/v1/admin/pipes/{name}` after boot. Inline `{{name:default}}` placeholders still work in a bootstrapped file, so the SQL can be parameter-ready even without formal declarations.
+:::
## End-to-end example
diff --git a/docs/src/content/docs/sdk.md b/docs/src/content/docs/sdk.md
deleted file mode 100644
index ce4e5bc2..00000000
--- a/docs/src/content/docs/sdk.md
+++ /dev/null
@@ -1,799 +0,0 @@
----
-title: "TypeScript SDK"
-description: "Zero-dependency client SDK — query builder, real-time streaming, codegen."
-sidebar:
- order: 8
----
-
-`@wavehouse/sdk` — Zero-dependency TypeScript client for WaveHouse.
-
-## Installation
-
-```bash
-npm install @wavehouse/sdk
-```
-
-This works in any bundler-based framework — React, Vue, Svelte, Angular, Astro, SolidJS, or plain Vite — via `import { createClient } from '@wavehouse/sdk'`.
-
-### No build step (CDN)
-
-No bundler or `npm` required — drop the SDK into a static HTML file you deploy over FTP, object storage, or any static host. The ES module loads natively in modern browsers:
-
-```html
-
-```
-
-Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.1.0`); jsDelivr (`.../+esm`) and unpkg (`?module`) serve the same module. For pages that can't use ES modules, the bundled IIFE build at `https://cdn.jsdelivr.net/npm/@wavehouse/sdk` exposes a `WaveHouse` global (`WaveHouse.createClient({ … })`) for a classic `
+ ```
+
+ Pin a version for production (`https://esm.sh/@wavehouse/sdk@0.1.0`);
+ jsDelivr (`.../+esm`) and unpkg (`?module`) serve the same module. For
+ pages that can't use ES modules, the bundled IIFE build at
+ `https://cdn.jsdelivr.net/npm/@wavehouse/sdk` exposes a `WaveHouse` global
+ (`WaveHouse.createClient({ … })`) for a classic `
+
+```
+
+### Runtime support
+
+**Browsers** — all SDK features work natively. `fetch`, `AbortController`,
+and `EventSource` are built into every modern browser; no polyfills are
+required.
+
+**Node.js** — non-streaming features (`wh.from().fetch()`, `.insert()`,
+`.sql()`, pipes, admin) work in Node 18 and later (the package's minimum,
+per `engines.node`). Streaming (`.stream()`, `.liveQuery()`) uses
+`EventSource`, which is **not** a default global in Node. The SDK
+feature-detects with `typeof EventSource === "undefined"` and throws a
+descriptive error if it is absent. To use streaming in Node you must
+polyfill before calling any stream method:
+
+```js
+import { EventSource } from 'eventsource'; // npm install eventsource
+globalThis.EventSource = EventSource;
+```
+
+`--experimental-eventsource` (available in recent Node releases) also
+satisfies the check when passed on the command line.
+
+## Quick Start
+
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+ ```ts
+ import { createClient } from '@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+ ```ts
+ import { createClient } from 'https://esm.sh/@wavehouse/sdk';
+
+ const wh = createClient({
+ baseURL: 'http://localhost:8080',
+ auth: async () => getAccessToken(), // omit for public/unauthenticated
+ });
+
+ // Query
+ const { data, error } = await wh.from('clicks').select('page').limit(10);
+
+ // Insert
+ await wh.from('clicks').insert({ page: '/home', button: 'signup' });
+
+ // Stream
+ const stream = wh.from('clicks').stream();
+ const unsub = stream.subscribe({
+ next: (event) => console.log(event.data),
+ status: (s) => console.log('Stream:', s),
+ });
+ ```
+
+
+
+## Creating a Client
+
+```ts
+import { createClient } from '@wavehouse/sdk';
+import type { Database } from './my-types'; // optional hand-written types
+
+const wh = createClient({
+ baseURL: 'https://wavehouse.example.com',
+ auth: async () => myAuthProvider.getToken(),
+ options: {
+ maxRetries: 2,
+ },
+});
+```
+
+### `ClientConfig`
+
+| Field | Type | Default | Description |
+|-------|------|---------|-------------|
+| `baseURL` | `string` | — | WaveHouse server URL (required) |
+| `auth` | `() => Promise \| string` | — | Token provider. Omit for public access |
+| `options.maxRetries` | `number` | `2` | Retry attempts for failed/5xx requests |
+
+:::note[How the token is transmitted]
+The SDK attaches your `auth` token as an `Authorization: Bearer` header on REST calls, and — because the browser `EventSource` API can't set headers — as a `?token=` query parameter on streaming connections. When both are present the server reads the header in preference to the query parameter, and strips the `?token=` value from the URL after extraction so it can't leak into logs.
+:::
+
+### Type-Safe Tables
+
+Pass a `Database` type to get autocomplete on table names and row types:
+
+```ts
+interface Database {
+ clicks: { page: string; button: string; score: number; received_timestamp: string };
+ users: { id: string; name: string; email: string };
+}
+
+const wh = createClient({ baseURL: '...' });
+const clicks = wh.from('clicks'); // ✅ autocomplete
+const { data } = await clicks.select('page', 'button').limit(10);
+// data is Array<{ page: string; button: string; score: number; received_timestamp: string }> | null
+```
+
+Generate the `Database` interface from a running server with the
+[codegen CLI](/sdk/reference#codegen-cli).
+
+## Result Type
+
+Every async SDK operation returns `Result` — a discriminated union that never throws:
+
+```ts
+type Result =
+ | { ok: true; data: T; error: null; hasMore?: boolean; next?: () => Promise> }
+ | { ok: false; data: null; error: WaveHouseError }
+
+interface WaveHouseError {
+ status: number; // HTTP status (0 for network errors)
+ code: string; // e.g. 'HTTP_400', 'NETWORK_ERROR', 'ABORTED'
+ message: string; // Human-readable error message
+ details?: unknown; // Raw response body
+ retryable: boolean; // Whether SDK would retry this error
+}
+```
+
+Usage pattern — branch on `result.ok` (or destructure `{ data, error }` if you prefer):
+
+```ts
+const result = await wh.from('clicks').select('page').limit(10);
+if (result.ok) {
+ console.log(result.data); // Row[] — TypeScript knows data is non-null here
+} else {
+ console.error(result.error.message); // never throws
+}
+```
+
+The full error-code table lives in
+[Error Handling](/sdk/reference#error-handling).
+
+## Explore the SDK
+
+
+
+
+
+
+
+
diff --git a/docs/src/content/docs/sdk/pipes.md b/docs/src/content/docs/sdk/pipes.md
new file mode 100644
index 00000000..3e1cca2f
--- /dev/null
+++ b/docs/src/content/docs/sdk/pipes.md
@@ -0,0 +1,53 @@
+---
+title: "SDK Pipes"
+description: "Execute and manage named query pipes with @wavehouse/sdk."
+---
+
+Named pipes are server-defined, parameterized queries — the
+[Named Pipes guide](/pipes) covers defining them. The SDK executes pipes for
+any allowed role and manages their definitions under the admin role.
+Examples import from `@wavehouse/sdk`; using the CDN instead, import from
+`https://esm.sh/@wavehouse/sdk` (see [Imports & Runtimes](/sdk#imports--runtimes)).
+
+## Named Pipes — `wh.pipe(name, params?)`
+
+Execute a pre-defined named query pipe. Returns a `PipeRef` which is **PromiseLike**.
+
+```ts
+// These are equivalent (PipeRef is PromiseLike):
+const { data } = await wh.pipe('top_pages', { start_date: '2026-01-01', limit: 50 }).fetch();
+const { data } = await wh.pipe('top_pages', { start_date: '2026-01-01', limit: 50 });
+```
+
+### `.fetch(opts?)`
+
+Execute and return results.
+
+### `.stream(opts?)`
+
+Open a live stream. See [Streaming](/sdk/streaming).
+
+---
+
+## Pipes Admin — `wh.pipes`
+
+Manage named query pipes. Requires the admin role (`policy.admin_role`).
+
+```ts
+// List all pipes
+const { data: pipes } = await wh.pipes.list();
+
+// Get a single pipe definition
+const { data: pipe } = await wh.pipes.get('top_pages');
+
+// Create or update
+await wh.pipes.set('top_pages', {
+ sql: 'SELECT page, count() as views FROM clicks GROUP BY page LIMIT {{limit}}',
+ parameters: [{ name: 'limit', type: 'number', required: false, default: 100 }],
+ description: 'Top pages by view count',
+ allowed_roles: ['viewer', 'admin'],
+});
+
+// Delete
+await wh.pipes.delete('old_pipe');
+```
diff --git a/docs/src/content/docs/sdk/queries.md b/docs/src/content/docs/sdk/queries.md
new file mode 100644
index 00000000..a240693a
--- /dev/null
+++ b/docs/src/content/docs/sdk/queries.md
@@ -0,0 +1,261 @@
+---
+title: "SDK Queries"
+description: "Tables, the chainable query builder, pagination, and raw SQL in @wavehouse/sdk."
+---
+
+Reading and writing data with `@wavehouse/sdk`: table references, the
+chainable query builder, cursor pagination, and the admin-only raw-SQL
+escape hatch. Every call returns the SDK's
+[`Result`](/sdk#result-type) — nothing throws.
+Examples import from `@wavehouse/sdk`; using the CDN instead, import from
+`https://esm.sh/@wavehouse/sdk` (see [Imports & Runtimes](/sdk#imports--runtimes)).
+
+## Tables — `wh.from(table)`
+
+`from()` returns a `TableRef` — a reference to a table. It is **NOT thenable**, so it's safe to pass around or store in a variable without triggering requests.
+
+```ts
+const clicks = wh.from('clicks');
+```
+
+### `.fetch(opts?)`
+
+Shortcut for "select every column", with a default limit of 1000. When an access-control policy restricts your role's columns, the server returns only the columns your role is allowed to read — `.fetch()` is never a way around `deny_columns`/`allow_columns` (see [Access control](/access-control#column-permissions)).
+
+To paginate, chain an explicit `.orderBy()` — a bare `.fetch()` sends no default order (see [Pagination](#pagination)). Ordering, grouping, or filtering by a column your role can't read is rejected, so a column-restricted role must reference only readable columns in those clauses.
+
+```ts
+const { data, error, hasMore, next } = await clicks.fetch();
+const { data } = await clicks.fetch({ limit: 50, signal: controller.signal });
+```
+
+### `.insert(data, opts?)`
+
+Insert one row or many. A single object is sent as a JSON `POST /v1/ingest?table={table}`. An **array** is serialized to NDJSON (one record per line) and sent as a single `application/x-ndjson` request, so a bad record no longer fails or hides the rest of the batch — per-record outcomes come back in the result.
+
+```ts
+// Single row → { ok: true } (or { ok: true, duplicate: true } when dedup skips it)
+const { data, error } = await clicks.insert({ page: '/home', button: 'cta' });
+
+// Many rows → one NDJSON request, per-record summary
+const { data } = await clicks.insert([
+ { page: '/home', button: 'cta' },
+ { page: '/about', button: 'nav' },
+]);
+// data: { ok, total, succeeded, failed, duplicates, results? }
+```
+
+For an array insert, `data.ok` is `true` only when every record succeeded (`failed === 0`). Inspect `data.failed` and `data.results` (each `{ index, ok|duplicate|error }`, 1-based `index`) for partial failures — the call's top-level `error` is reserved for whole-request failures (network, `404` unknown table, `403` forbidden, `503` backpressure). An empty array is a no-op and sends no request. The array path sends one request regardless of size; bounded-concurrency chunking of very large arrays is tracked in [#196](https://github.com/Wave-RF/WaveHouse/issues/196).
+
+> The server itself is format-agnostic: `POST /v1/ingest` also accepts a raw JSON array or a single object directly (the `Content-Type` is only a hint), so non-SDK clients can send whichever shape is convenient. See the [API reference](/api#post-v1ingesttabletable--ingest-data).
+
+### `.insertNDJSON(source, opts?)`
+
+Insert pre-formatted NDJSON you already have — a `.ndjson` file, a byte stream, or a string — without first parsing it into objects. Accepts a `string`, `Uint8Array`, `Blob`/`File`, or `ReadableStream`; non-string sources are read fully into memory before sending. Returns the same per-record summary as an array `insert`.
+
+```ts
+// From a string
+await clicks.insertNDJSON('{"page":"/a"}\n{"page":"/b"}\n');
+
+// From a browser (a File is a Blob)
+await clicks.insertNDJSON(fileInput.files[0]);
+
+// From a Node file (Node 20+: fs.openAsBlob; or read it to a string)
+import { openAsBlob } from 'node:fs';
+await clicks.insertNDJSON(await openAsBlob('events.ndjson'));
+```
+
+### `.schema(opts?)`
+
+Fetch the table's column definitions from ClickHouse.
+
+```ts
+const { data } = await clicks.schema();
+// data: { name: 'clicks', columns: [
+// { name: 'page', type: 'String', is_nullable: false, has_default: false }, ...
+// ] }
+```
+
+### `.select(...columns)`
+
+Start a query builder chain. See [Query Builder](#query-builder).
+
+```ts
+const { data } = await clicks.select('page', 'button').where('page', '=', '/home').limit(10);
+```
+
+### `.selectAll()`
+
+Start a query that selects **every column your role is allowed to read** — the explicit form of what a bare `.fetch()` does. Mutually exclusive with `.select(...)` and with aggregations (`.count()`, `.sum()`, etc.); the server expands it to your allowed columns (never a raw `SELECT *`) and never bypasses `deny_columns`/`allow_columns`. See [Access control → Column permissions](/access-control#column-permissions).
+
+```ts
+const { data } = await clicks.selectAll().where('country', '=', 'US').limit(10);
+```
+
+### `.stream(opts?)`
+
+Open a real-time event subscription. See [Streaming](/sdk/streaming).
+
+```ts
+const stream = clicks.stream({ since: '2026-01-01T00:00:00Z' });
+```
+
+---
+
+## Query Builder
+
+Returned by `tableRef.select()`. Immutable — every chain method returns a new `QueryBuilder`. The builder is **PromiseLike**, so `await builder` auto-executes `.fetch()`.
+
+```ts
+// These are equivalent:
+const result = await clicks.select('page').limit(10).fetch();
+const result = await clicks.select('page').limit(10); // PromiseLike shortcut
+```
+
+### Chain Methods
+
+All methods return a new `QueryBuilder` — the original is unchanged.
+
+#### `.select(...columns)`
+
+Append columns to the SELECT clause. A literal `'*'` is the column *named* `*`, not a wildcard — use `.selectAll()` for all columns.
+
+```ts
+const q = clicks.select('page').select('button'); // SELECT page, button
+```
+
+#### `.selectAll()`
+
+Select every column your role may read (the all-columns wildcard, expanded server-side to your allowed columns). Mutually exclusive with `.select(...)` and with aggregations (`.count()`, `.sum()`, etc.).
+
+```ts
+const q = clicks.selectAll().where('country', '=', 'US');
+```
+
+#### `.where(column, op, value)`
+
+Add a filter condition. SDK operators are translated to backend format.
+
+```ts
+clicks.select('page').where('score', '>', 10).where('page', 'like', '/home%')
+```
+
+| SDK Operator | Backend | Description |
+|-------------|---------|-------------|
+| `'='` | `eq` | Equal |
+| `'!='` | `neq` | Not equal |
+| `'>'` | `gt` | Greater than |
+| `'>='` | `gte` | Greater than or equal |
+| `'<'` | `lt` | Less than |
+| `'<='` | `lte` | Less than or equal |
+| `'in'` | `in` | Value in array |
+| `'like'` | `like` | SQL LIKE pattern |
+| `'not_like'` | — | SQL NOT LIKE — **client-side only** (live-query / stream filtering); the `/v1/query` backend rejects it |
+
+#### Aggregations
+
+```ts
+clicks.select('page')
+ .count('*', 'total') // COUNT(*)
+ .sum('score', 'total_score') // SUM(score)
+ .avg('score', 'avg_score') // AVG(score)
+ .min('score', 'min_score') // MIN(score)
+ .max('score', 'max_score') // MAX(score)
+ .countDistinct('page', 'unique_pages')
+ .aggregate('uniqExact', 'user_id', 'unique_users') // custom fn
+```
+
+Each aggregation method signature: `(column: string, alias?: string)`.
+`count()` defaults to `column='*'`, `alias='count'`.
+
+#### `.groupBy(...columns)`
+
+```ts
+clicks.select('page').count().groupBy('page')
+```
+
+#### `.orderBy(column, dir?)`
+
+```ts
+clicks.select('page').count('*', 'total').orderBy('total', 'desc')
+```
+
+`dir` defaults to `'asc'`.
+
+#### `.limit(n)`
+
+```ts
+clicks.select().limit(100)
+```
+
+If no limit is specified, `QueryBuilder.DEFAULT_LIMIT` (1000) is applied automatically to prevent unbounded result sets. The server also enforces a maximum of 10,000 rows (`DefaultMaxRows`).
+
+#### `.timeRange(column, since, until?)`
+
+Filter by a time window. `since` and `until` accept RFC3339 timestamps or relative durations (`'1h'`, `'30m'`, `'7d'`, `'2w'` — day and week suffixes expand to hours, so `'7d'` is `'168h'`).
+
+```ts
+clicks.select('page').timeRange('received_timestamp', '1h')
+clicks.select('page').timeRange(
+ 'received_timestamp', '2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z'
+)
+```
+
+#### `.cacheTTL(seconds)`
+
+Records a desired result-cache TTL on the builder. **Currently client-side state only** — the value is never sent to the server, which derives each result's cache TTL adaptively from query execution time. Wiring it through the wire format is tracked in [#280](https://github.com/Wave-RF/WaveHouse/issues/280).
+
+```ts
+clicks.select('page').count().cacheTTL(300) // not yet honored server-side — see #280
+```
+
+### `.fetch(opts?)`
+
+Execute the query. Returns `Result` with optional pagination.
+
+```ts
+const { data, error, hasMore, next } = await clicks.select('page').limit(50).fetch();
+
+if (hasMore && next) {
+ const page2 = await next(); // cursor-based pagination
+}
+```
+
+**Options:**
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `signal` | `AbortSignal` | Cancel the request |
+| `limit` | `number` | Override builder limit for this fetch |
+
+### `.stream(opts?)`
+
+Open a live stream from the builder's table. See [Streaming](/sdk/streaming).
+
+### Pagination
+
+When `limit` is set and the result contains at least `limit` rows, `hasMore` is `true`. Cursor-based pagination's `next()` walks an **order column** — it adds a filter on that column using the last row's value — so `next()` is only attached when the query has an explicit `.orderBy()`. With no order column the result still reports `hasMore` honestly, but `next` is `undefined` (there is no deterministic cursor to build) — add an `.orderBy()` to paginate.
+
+```ts
+let result = await clicks.select().orderBy('received_timestamp', 'desc').limit(100).fetch();
+
+const allRows = [...result.data!];
+while (result.hasMore && result.next) {
+ result = await result.next();
+ if (result.data) allRows.push(...result.data);
+}
+```
+
+---
+
+## Raw SQL — `wh.sql(query, opts?)`
+
+Execute a raw SQL query. `/v1/admin/query` is admin-only: the caller's JWT must resolve to the policy admin role (`admin_role`, `"admin"` by default). A request with no token, or an invalid/expired one, falls back to the `default_role` and is rejected.
+
+```ts
+const { data, error } = await wh.sql('SELECT page, count() FROM clicks GROUP BY page LIMIT 10');
+```
+
+:::note[No parameter binding through the SDK]
+Positional `?` substitution is not supported, and the SDK has no way to forward ClickHouse-style named params (the `WHERE id = {id:UInt32}` + `param_id=42` query-string combo) — the proxy doesn't forward arbitrary query-string params and `wh.sql()` doesn't expose a hook to add them. Inline literals into the SQL, or — for safe binding from user-supplied input — use the structured query builder (`wh.from(table)…`).
+:::
diff --git a/docs/src/content/docs/sdk/reference.md b/docs/src/content/docs/sdk/reference.md
new file mode 100644
index 00000000..f631d1a7
--- /dev/null
+++ b/docs/src/content/docs/sdk/reference.md
@@ -0,0 +1,154 @@
+---
+title: "SDK Reference & CLI"
+description: "Error codes, AbortController, the full API tree, the codegen CLI, and E2E testing with @wavehouse/sdk."
+---
+
+Cross-cutting reference for `@wavehouse/sdk`: cancellation, the error model
+behind every [`Result`](/sdk#result-type), the complete API tree at a
+glance, and the tooling that ships in the package.
+
+## AbortController Support
+
+All async operations accept an `AbortSignal` for cancellation:
+
+```ts
+const controller = new AbortController();
+setTimeout(() => controller.abort(), 5000); // 5s timeout
+
+const { data, error } = await wh.from('clicks').fetch({ signal: controller.signal });
+if (error?.code === 'ABORTED') {
+ console.log('Request timed out');
+}
+```
+
+---
+
+## Error Handling
+
+The SDK **never throws**. All errors are returned in `Result.error`.
+
+| Status | Code | Retryable | Description |
+|--------|------|-----------|-------------|
+| 400 | `HTTP_400` | No | Bad request (validation, missing fields) |
+| 401 | `HTTP_401` | No | Missing or invalid JWT |
+| 403 | `HTTP_403` | No | Insufficient permissions |
+| 404 | `HTTP_404` | No | Table or pipe not found |
+| 500 | `HTTP_500` | Yes | Server error (retried per `maxRetries`) |
+| 503 | `HTTP_503` | Yes | Service unavailable (auto-retries with `Retry-After`) |
+| 0 | `NETWORK_ERROR` | Yes | Network failure (retried with exponential backoff) |
+| 0 | `ABORTED` | No | Request canceled via `AbortSignal` |
+
+---
+
+## Full API Tree
+
+```text
+createClient(config) → WaveHouseClient
+├── .from(table) → TableRef (NOT thenable)
+│ ├── .fetch(opts?) → Promise>
+│ ├── .select(...cols?) → QueryBuilder (PromiseLike)
+│ │ ├── .select() .selectAll() .where() .count() .sum() .avg() .min() .max()
+│ │ │ .countDistinct() .aggregate() .groupBy() .orderBy()
+│ │ │ .limit() .timeRange() .cacheTTL()
+│ │ ├── .fetch(opts?) → Promise>
+│ │ ├── .stream(opts?) → StreamController
+│ │ └── .liveQuery(subscriber, opts?) → LiveQuery
+│ ├── .selectAll() → QueryBuilder (PromiseLike)
+│ ├── .insert(data) → Promise>
+│ ├── .insertNDJSON(source) → Promise>
+│ ├── .schema() → Promise>
+│ └── .stream(opts?) → StreamController
+├── .pipe(name, params?) → PipeRef (PromiseLike)
+│ ├── .fetch(opts?) → Promise>
+│ └── .stream(opts?) → StreamController
+├── .pipes (admin)
+│ ├── .list() → Promise>
+│ ├── .get(name) → Promise>
+│ ├── .set(name, def) → Promise>
+│ └── .delete(name) → Promise>
+├── .sql(query, opts?) → Promise>
+├── .schema
+│ ├── .list() → Promise>
+│ └── .refresh() → Promise>
+├── .policy (admin)
+│ ├── .get() → Promise>
+│ ├── .set(policy) → Promise>
+│ └── .validate(policy) → Promise>
+├── .dlq
+│ ├── .list() → Promise>
+│ ├── .table(name) → Promise>
+│ └── .stream() → StreamController // not yet functional server-side — #197
+└── .sys
+ └── .health() → Promise>
+
+StreamController (NOT thenable)
+├── .subscribe({ next, status?, error? }) → unsubscribe()
+├── .close()
+├── .status → StreamStatus
+└── [Symbol.asyncIterator]() → AsyncIterableIterator
+```
+
+## Codegen CLI
+
+Generate TypeScript types from a running WaveHouse instance. The package ships a `wavehouse-codegen` bin, so after installing `@wavehouse/sdk` you can run it with `npx`:
+
+```bash
+npx wavehouse-codegen --url http://localhost:8080 --out ./src/db.d.ts
+
+# Or, working inside this repo (clients/ts/):
+pnpm codegen --url http://localhost:8080 --out ./src/db.d.ts
+```
+
+Codegen reads `/v1/schema`, which is **admin-only**. Against a non-dev server, pass an admin-role token with `--auth ` or the request is denied with `403`.
+
+**Options:**
+
+| Flag | Description | Default |
+|------|-------------|---------|
+| `--url`, `-u` | WaveHouse base URL | `http://localhost:8080` |
+| `--out`, `-o` | Output .d.ts file path | `./wavehouse.d.ts` |
+| `--auth`, `-a` | Bearer token (if auth required) | — |
+
+**Example output:**
+
+```ts
+// Auto-generated by @wavehouse/sdk codegen
+export interface Database {
+ clicks: ClicksRow;
+ events: EventsRow;
+}
+
+export interface ClicksRow {
+ event_id: string;
+ page: string;
+ user_id: string;
+ duration_ms: number;
+ received_timestamp: string;
+}
+```
+
+**ClickHouse → TypeScript type mapping:**
+
+| ClickHouse Type | TypeScript Type |
+|----------------|-----------------|
+| `String`, `FixedString`, `UUID`, `DateTime*`, `Date*`, `Enum*`, `IPv4/6` | `string` |
+| `UInt*`, `Int*`, `Float*`, `Decimal*` | `number` |
+| `Bool` | `boolean` |
+| `Nullable(T)` | `T \| null` |
+| `Array(T)` | `T[]` |
+| `Map(K, V)` | `Record` |
+| `LowCardinality(T)` | same as `T` |
+
+## E2E Testing
+
+The SDK doubles as the E2E integration test harness. Tests in `tests/e2e/sdk/` exercise the full pipeline (ingest → ClickHouse → query) through the SDK, validating both the backend and the client library in one pass.
+
+```bash
+# Run all E2E tests: the orchestrator boots a ClickHouse testcontainer +
+# the wavehouse-cov binary, then runs the SDK suite
+make test-e2e
+```
+
+Test files live in `tests/e2e/sdk/`: `admin`, `auth`, `batching`, `cache`, `dlq`, `ingest`, `ndjson`, `query`, `streaming`, `stress` (each `*.test.ts`).
+
+See [Development Guide — E2E Tests via SDK](/development#e2e-tests-via-sdk) for architecture details and workflow tips.
diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md
new file mode 100644
index 00000000..2a9868a5
--- /dev/null
+++ b/docs/src/content/docs/sdk/streaming.md
@@ -0,0 +1,160 @@
+---
+title: "SDK Streaming & Live Queries"
+description: "Real-time SSE streams, client-side filtering, and backfill-then-live queries in @wavehouse/sdk."
+---
+
+Real-time consumption with `@wavehouse/sdk`: SSE event streams from tables,
+builders, and pipes, plus live queries that backfill history before going
+live. Builders and table refs come from [Queries](/sdk/queries).
+Examples import from `@wavehouse/sdk`; using the CDN instead, import from
+`https://esm.sh/@wavehouse/sdk` (see [Imports & Runtimes](/sdk#imports--runtimes)).
+
+## Streaming
+
+Streams use SSE (Server-Sent Events) for both unauthenticated connections and for authenticated ones.
+
+### `StreamController`
+
+Returned by `.stream()` on `TableRef`, `QueryBuilder`, `PipeRef`, and `DLQNamespace` (the DLQ variant is not yet functional server-side — [#197](https://github.com/Wave-RF/WaveHouse/issues/197)). It is **NOT thenable**.
+
+```ts
+const stream = wh.from('clicks').stream({ since: '2026-01-01T00:00:00Z' });
+```
+
+### `.subscribe(subscriber)` → `unsubscribe()`
+
+Callback-based consumption. Returns a cleanup function.
+
+```ts
+const unsub = stream.subscribe({
+ next: (event) => {
+ // event: { table: 'clicks', timestamp: '2026-...', data: { page: '/', ... } }
+ console.log('New event:', event.data);
+ },
+ status: (state) => {
+ // state: 'connecting' | 'live' | 'reconnecting' | 'closed'
+ updateIndicator(state);
+ },
+ error: (err) => {
+ console.error('Stream error:', err.message);
+ },
+});
+
+// Cleanup — closes the connection if no other subscribers remain
+unsub();
+```
+
+### Async Iterator
+
+```ts
+const stream = wh.from('clicks').stream();
+
+for await (const event of stream) {
+ console.log(event.table, event.data);
+ if (shouldStop) break; // breaking auto-closes the stream
+}
+```
+
+### `.close()`
+
+Explicitly close the stream and release all resources.
+
+```ts
+stream.close();
+```
+
+### `.status`
+
+Current connection status: `'connecting' | 'live' | 'reconnecting' | 'closed'`.
+
+### `StreamOptions`
+
+| Field | Type | Description |
+| ----- | ---- | ----------- |
+| `since` | `string` | RFC3339 timestamp for gap-fill replay |
+| `signal` | `AbortSignal` | Cancel/close the stream. Wired via `attachSignal()` internally. |
+
+### `StreamEvent`
+
+```ts
+interface StreamEvent {
+ table: string; // table name (e.g. 'clicks')
+ timestamp: string; // received_timestamp (RFC3339Nano)
+ data: T; // row data
+}
+```
+
+### Transport Behavior
+
+| Transport | Reconnect | Protocol |
+| --------- | --------- | -------- |
+| SSE | Automatic (native `EventSource` with `Last-Event-ID`) | HTTP/2 recommended |
+
+
+
+:::note[SSE connection limit]
+The SDK warns when more than 5 concurrent SSE connections are open (browser limit per domain).
+:::
+
+### Client-Side Stream Filtering
+
+When a `QueryBuilder` with `.where()` filters or `.select()` columns calls `.stream()`, the returned stream applies those filters client-side:
+
+```ts
+const stream = wh.from('clicks')
+ .select('page', 'button')
+ .where('page', '=', '/home')
+ .stream();
+
+// Only events where page === '/home' are emitted, with only page + button columns
+```
+
+Supported operators: `=`, `!=`, `>`, `>=`, `<`, `<=`, `in`, `like`, `not_like` — the same `FilterOp` set `.where()` takes everywhere (the SDK maps them to wire tokens such as `eq`/`neq` internally).
+
+---
+
+## Live Queries
+
+Live queries combine a historical backfill (`.fetch()`) with a real-time stream, providing a seamless initial load + live updates experience.
+
+```ts
+const lq = wh.from('clicks')
+ .selectAll()
+ .where('page', '=', '/home')
+ .orderBy('received_timestamp', 'desc')
+ .limit(100)
+ .liveQuery({
+ initial: (result) => {
+ // Called once with the historical data
+ setRows(result.data ?? []);
+ },
+ next: (event) => {
+ // Called for each live event after backfill
+ addRow(event.data);
+ },
+ error: (err) => console.error(err),
+ });
+
+// Cleanup
+lq.close();
+```
+
+### `StreamSubscriber`
+
+```ts
+interface StreamSubscriber {
+ initial?: (result: Result) => void; // Historical backfill result
+ next: (event: StreamEvent) => void; // Live events
+ status?: (state: string) => void; // Connection state changes
+ error?: (err: WaveHouseError) => void; // Errors
+}
+```
+
+### How it works
+
+1. Opens the stream **immediately** and buffers incoming events.
+2. Runs the `.fetch()` query for historical data, calls `subscriber.initial()` with the result.
+3. Deduplicates buffered events by comparing timestamps against the latest historical timestamp.
+4. Flushes remaining buffered events and switches to live mode.
+
+This "stream-first" approach ensures no events are lost between the fetch and stream start.
diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css
index a985ae65..dbd000df 100644
--- a/docs/src/styles/global.css
+++ b/docs/src/styles/global.css
@@ -230,7 +230,9 @@
--sl-color-black: var(--wh-bg);
--sl-color-bg: var(--wh-bg);
- --sl-color-bg-nav: color-mix(in oklab, var(--wh-bg) 78%, transparent);
+ /* 88%, was 78% — scrolled body text stayed legible through the glass and
+ collided visually with the search trigger (fresh-eyes audit). */
+ --sl-color-bg-nav: color-mix(in oklab, var(--wh-bg) 88%, transparent);
--sl-color-bg-sidebar: var(--wh-bg);
--sl-color-bg-inline-code: var(--wh-code-bg);
--sl-color-hairline: var(--wh-border);
@@ -247,14 +249,14 @@
--sl-text-h4: 1.125rem;
--sl-text-h5: 1rem;
- --sl-sidebar-width: 18.5rem;
- /* Mobile/tablet default — fits a 768px iPad without clipping (the old 50rem
- slightly overflowed there). Widened to 58rem only at the `lg` breakpoint
- below, where the 3-column layout has room: code/tables/wide Mermaid then
- use the space on large monitors while prose line length stays held by the
- .sl-markdown-content text-block cap. The shell is also capped + centered
- on ultrawide via .main-frame so content+TOC don't strand dead space. */
- --sl-content-width: 45rem;
+ /* Layout geometry stays at Starlight's stock values (18.75rem sidebar,
+ ONE 45rem content width at every breakpoint). A previous pass widened
+ the content track in steps (50rem @72rem, 58rem @100rem) and sub-capped
+ prose at 46rem — which left text, tables, and code all ending at
+ different right edges and shifted the column at each breakpoint. One
+ width keeps every block aligned and preserves the TOC's designed share
+ of the leftover space. Wide tables/diagrams scroll or scale inside
+ their own wrappers. */
--sl-nav-height: 4rem;
--sl-shadow-sm: var(--wh-shadow-sm);
@@ -272,24 +274,6 @@
--sl-color-gray-7: var(--wh-surface-elevated);
}
-/* Content track widens in two steps so it never starves the right-rail TOC
- (content does NOT shrink below --sl-content-width in this layout, so an
- over-wide track squeezes the TOC into ellipsis). 50rem through the laptop
- range is exactly the pre-sweep width — no TOC regression there. The full
- 58rem engages only ≥100rem, the same breakpoint where .main-frame caps +
- centers the shell: at ≥1600px there's unambiguous room for sidebar + 58rem
- content + a comfortable TOC, and the two changes reinforce each other. */
-@media (min-width: 72rem) {
- :root {
- --sl-content-width: 50rem;
- }
-}
-@media (min-width: 100rem) {
- :root {
- --sl-content-width: 58rem;
- }
-}
-
/* ---------- Mermaid diagram polish ----------
These rules live at the document level (not in each SVG's inline