From ddaffa934bacec97b289487268049f848895e7c9 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Tue, 9 Jun 2026 22:25:28 -0400 Subject: [PATCH 01/23] feat(docs): view-transition polish, branded search + 404, chrome pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit View transitions (astro-vtbot, SPA mode): - Direction-aware 150ms slide on
, keyed to sidebar order (), so next/prev pages slide like paging a book - kills the fake-scroll artifact when navigating away from a scrolled page - Page-title morph between doc pages via view-transition-name on the title-panel h1 (direct-child selector so markdown h1s can't duplicate the name and void the transition) - Self-hosted navigation progress bar on vtbot's loading() hook — vtbot's own imports Swup's plugin from unpkg.com at runtime in visitors' browsers, which fails our supply-chain bar - Belt-and-braces reduced-motion guard on all VT pseudo-elements Starlight chrome: - Search dialog branded: surface/border/radius on the frame, code-bg input with accent focus, card-primitive result groups, accent highlights, blurred backdrop - Asides mapped onto brand hues (stock blue/purple/orange/red tokens were never remapped, so the first :::note would land off-palette) - kbd/mark prose treatments, accent anchor-link hover, thin branded scrollbars on inner scroll surfaces, mobile menu button kept on the surface scale instead of stock white inversion 404 page: "signal lost" treatment — landing atmosphere (grid + glow), mono 404, flatlining waveform, card quick-links; custom h1 replaces the hidden stock title panel so the page keeps exactly one heading. PostHog: capture_exceptions enabled (error tracking for docs JS); the SPA pageview setup (capture_pageview: history_change) was verified correct against the vtbot lifecycle docs and needs no change. Co-Authored-By: Claude Fable 5 --- docs/src/components/Head.astro | 26 +- docs/src/components/PostHog.astro | 5 +- docs/src/components/ProgressBar.astro | 57 +++++ docs/src/content/docs/404.md | 30 ++- docs/src/styles/global.css | 345 +++++++++++++++++++++++++- 5 files changed, 446 insertions(+), 17 deletions(-) create mode 100644 docs/src/components/ProgressBar.astro diff --git a/docs/src/components/Head.astro b/docs/src/components/Head.astro index d9c4922e..6799b811 100644 --- a/docs/src/components/Head.astro +++ b/docs/src/components/Head.astro @@ -1,7 +1,11 @@ --- import Default from "@astrojs/starlight/components/Head.astro"; +import PageOffset from "astro-vtbot/components/PageOffset.astro"; import VtbotStarlight from "astro-vtbot/components/starlight/Base.astro"; +import PageOrder from "astro-vtbot/components/starlight/PageOrder.astro"; +import { slide } from "astro:transitions"; import PostHog from "./PostHog.astro"; +import ProgressBar from "./ProgressBar.astro"; --- {/* @@ -11,9 +15,29 @@ import PostHog from "./PostHog.astro"; detached them (showModal on a removed dialog). fallback="swap" = instant swap where the View Transition API is absent. See Starlight #2823; full rationale in CHANGELOG. + + The transition:* directives don't act on this head component — vtbot's + StarlightConnector teleports them onto Starlight's
, the element its + actually swaps (Markdown pages can't carry directives). + Around that one animated element: + - flips the slide direction by sidebar order, so "next page" + slides forward and "previous page" slides back, like paging a book. + - cancels the fake-scroll artifact when + navigating away from a scrolled-down page (the old content would + visibly scroll to align with the new page's scroll position). + - (ours, not vtbot's — see the component) is a top + loading bar for slow fetches; a grace delay keeps fast cached + navigations from ever seeing it. */} - + + + + {/* Two media-scoped theme-color tags so the browser chrome (mobile address bar, diff --git a/docs/src/components/PostHog.astro b/docs/src/components/PostHog.astro index be1ba3a4..308f3b25 100644 --- a/docs/src/components/PostHog.astro +++ b/docs/src/components/PostHog.astro @@ -28,7 +28,10 @@ const apiHost = api_host: apiHost, ui_host: 'https://us.posthog.com', defaults: '2026-01-30', - capture_pageview: 'history_change' + capture_pageview: 'history_change', + // Surface broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in + // PostHog error tracking — volume on a docs site is negligible. + capture_exceptions: true }) } diff --git a/docs/src/components/ProgressBar.astro b/docs/src/components/ProgressBar.astro new file mode 100644 index 00000000..7f740449 --- /dev/null +++ b/docs/src/components/ProgressBar.astro @@ -0,0 +1,57 @@ +--- +// Navigation progress bar for slow loads, driven by astro-vtbot's loading() +// hook — the same extension point vtbot's own uses. We don't +// ship that component because it imports Swup's progress plugin from +// unpkg.com at runtime in every visitor's browser (a third-party CDN script +// in prod). This is the same behavior, self-hosted: zero external fetches. +// Styles live in global.css (#wh-progress-bar). +--- + + diff --git a/docs/src/content/docs/404.md b/docs/src/content/docs/404.md index a4ab8fae..191b3606 100644 --- a/docs/src/content/docs/404.md +++ b/docs/src/content/docs/404.md @@ -1,5 +1,5 @@ --- -title: "Page Not Found" +title: "Page not found" description: "The page you were looking for doesn't exist." template: splash editUrl: false @@ -11,11 +11,25 @@ head: content: noindex --- -The page you were looking for isn't here. A few common starting points: + -- **[Getting Started](/getting-started)** — install, run, and ingest your first event -- **[Architecture](/architecture)** — how the pieces fit together -- **[API Reference](/api)** — endpoints, payloads, and error semantics -- **[TypeScript SDK](/sdk)** — typed client for browser and Node - -If you followed a link from elsewhere and think it should have worked, please [file an issue](https://github.com/Wave-RF/WaveHouse/issues/new). +
+ + +

Signal lost

+

There's no page at this address — the link may be stale, or the page may have moved. One of these will get you back on the air:

+ +

Followed a link that should have worked? File an issue — broken links are bugs.

+
diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index a985ae65..90ed1cc2 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -925,20 +925,218 @@ a.sl-link-button.minimal:hover { } /* Search trigger — brand the most-visible header control to match the nav. */ -site-search button { +site-search > button { background: var(--wh-surface); border-color: var(--wh-border); transition: border-color 0.15s ease, background 0.15s ease; } -site-search button:hover { +site-search > button:hover { border-color: var(--wh-accent); background: var(--wh-surface-elevated); } -site-search button kbd { +site-search > button kbd { background: var(--wh-surface-overlay); color: var(--wh-ink-muted); } +/* ---------- Search dialog (Pagefind) ---------- + The modal itself shipped stock: near-black frame, pill input, gray result + list — the one overlay every visitor opens, unbranded. Starlight renders + Pagefind's default UI inside , so everything here + hangs off Pagefind's stable `pagefind-ui__*` hooks plus the dialog frame. */ +site-search dialog { + background: var(--wh-surface); + border: 1px solid var(--wh-border-strong); + box-shadow: var(--wh-shadow-lg); +} +/* Starlight's dialog is an edge-to-edge sheet below `md` (50rem) — only round + the corners where it actually floats. */ +@media (min-width: 50rem) { + site-search dialog { + border-radius: var(--radius-lg); + } +} +site-search dialog::backdrop { + background: color-mix(in oklab, var(--wh-bg) 55%, transparent); + backdrop-filter: blur(8px) saturate(120%); + -webkit-backdrop-filter: blur(8px) saturate(120%); +} +/* The input: code-surface + square radius to match the header trigger (the + stock pill was the lone fully-round control on the site). Pagefind paints + its own magnifier via a CSS mask on ::before. */ +site-search .pagefind-ui__search-input { + background: var(--wh-code-bg); + border: 1px solid var(--wh-border-strong); + border-radius: var(--radius-sm); + font-weight: 500; + color: var(--wh-ink); +} +site-search .pagefind-ui__search-input:focus { + border-color: var(--wh-accent); + outline: none; + box-shadow: 0 0 0 3px var(--wh-accent-glow); +} +site-search .pagefind-ui__search-input::placeholder { + color: var(--wh-ink-subtle); + opacity: 1; +} +site-search .pagefind-ui__message { + color: var(--wh-ink-subtle); + font-size: 0.8125rem; + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; +} +/* Result groups read as cards — same primitive as link cards everywhere else. */ +site-search .pagefind-ui__result { + background: var(--wh-surface-elevated); + border: 1px solid var(--wh-border); + border-radius: var(--radius-md); +} +site-search .pagefind-ui__result-link { + color: var(--wh-ink); +} +site-search .pagefind-ui__result-link:hover { + color: var(--wh-accent-text); +} +site-search .pagefind-ui__result-excerpt { + color: var(--wh-ink-muted); +} +/* Query highlight inside excerpts — accent wash instead of UA yellow. */ +site-search .pagefind-ui__result mark { + background: var(--wh-accent-glow-strong); + color: var(--wh-ink); + border-radius: 2px; + padding-inline: 1px; +} + +/* ---------- Asides — brand the four variants ---------- + Starlight's defaults run on its stock blue/purple/orange/red tokens, which + this design system never remapped — the first `:::note` written would land + in off-palette colors. Mirror the blockquote treatment (thin rail, faint + tint, rounded outer corners) on brand hues. The --wh-amber/emerald/rose + roles are already AA-adjusted per theme in the token blocks above. */ +.starlight-aside { + border-inline-start-width: 3px; + border-radius: 0 var(--radius-md) var(--radius-md) 0; +} +.starlight-aside--note { + --sl-color-asides-text-accent: var(--wh-accent-text); + --sl-color-asides-border: var(--wh-accent); + background-color: color-mix(in oklab, var(--wh-accent) 8%, transparent); +} +.starlight-aside--tip { + --sl-color-asides-text-accent: var(--wh-emerald); + --sl-color-asides-border: var(--wh-emerald); + background-color: color-mix(in oklab, var(--wh-emerald) 8%, transparent); +} +.starlight-aside--caution { + --sl-color-asides-text-accent: var(--wh-amber); + --sl-color-asides-border: var(--wh-amber); + background-color: color-mix(in oklab, var(--wh-amber) 8%, transparent); +} +.starlight-aside--danger { + --sl-color-asides-text-accent: var(--wh-rose); + --sl-color-asides-border: var(--wh-rose); + background-color: color-mix(in oklab, var(--wh-rose) 8%, transparent); +} + +/* ---------- Small chrome the design system hadn't reached ---------- */ + +/* Heading anchor links — quiet by default, accent on hover (stock gray). */ +.sl-anchor-link { + color: var(--wh-ink-subtle); + transition: color 0.12s ease; +} +.sl-heading-wrapper:hover .sl-anchor-link, +.sl-anchor-link:hover { + color: var(--wh-accent-text); +} + +/* in prose — chip treatment matching the search trigger's shortcut. */ +.sl-markdown-content kbd { + font-family: var(--sl-font-mono); + font-size: 0.8em; + padding: 0.15em 0.45em; + background: var(--wh-surface-elevated); + border: 1px solid var(--wh-border-strong); + border-bottom-width: 2px; + border-radius: var(--radius-xs); + color: var(--wh-ink); +} + +/* Highlighted text in prose — same accent wash as search hits. */ +.sl-markdown-content mark { + background: var(--wh-accent-glow-strong); + color: var(--wh-ink); + border-radius: 2px; + padding-inline: 2px; +} + +/* Inner scroll surfaces (sidebar, code frames, table/diagram wrappers) get a + thin brand-colored scrollbar instead of the chunky UA default on Windows/ + Linux. Deliberately not on :root — a thin PAGE scrollbar is hostile. */ +.sidebar-pane, +.right-sidebar, +.expressive-code pre, +.sl-markdown-content :where(p, div):has(> svg[aria-roledescription^="flowchart"]), +.sl-markdown-content :where(p, div):has(> svg[aria-roledescription^="sequence"]) { + scrollbar-width: thin; + scrollbar-color: var(--wh-border-strong) transparent; +} + +/* Mobile menu button — stock inverts to a white circle when open, the + harshest element on a dark screen. Keep it on the surface scale. */ +starlight-menu-button button[aria-expanded="true"] { + background-color: var(--wh-surface-overlay); + color: var(--wh-ink); + box-shadow: 0 0 0 1px var(--wh-border-strong); +} + +/* ---------- View transitions (astro-vtbot SPA mode) ---------- + The animated element is Starlight's
— Head.astro's directives are + teleported onto it (transition:name="main" + a 150ms slide, direction-aware + via ). Everything here styles the supporting cast. */ + +/* Navigation progress bar (src/components/ProgressBar.astro drives width/ + opacity inline; everything visual lives here). Accent line over the glass + header; only slow loads ever see it. */ +#wh-progress-bar { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0; + opacity: 0; + z-index: 100; /* above header.header (the fixed glass nav) */ + background: linear-gradient(90deg, var(--wh-accent), var(--wh-accent-hi)); + box-shadow: 0 0 8px var(--wh-accent-glow-strong); + pointer-events: none; +} + +/* Page-title morph: doc titles cross-fade/glide into each other instead of + sliding away with the content — the one element a reader visually tracks + between pages. The direct-child chain targets ONLY Starlight's title panel + (one per page); a markdown `# h1` or the 404's custom title must never + match, since a duplicated view-transition-name voids the whole transition. + The landing hero title deliberately stays inside the main slide. + width:fit-content keeps the morph snapshot hugging the text (vtbag's tip) — + a full-width snapshot stretches the short→long title morph. */ +.content-panel > .sl-container > h1 { + view-transition-name: page-title; + width: fit-content; +} + +/* Belt-and-braces: no transition choreography for reduced-motion users, even + if a future router/browser change stops handling it upstream. */ +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } +} + /* ---------- Utility classes for landing page ---------- */ /* ---------- Landing atmosphere ---------- @@ -959,12 +1157,14 @@ site-search button kbd { Unlayered (Starlight's rule is in @layer starlight.reset, so this wins on origin regardless of specificity) and scoped to :has(.wh-hero) so only the landing is affected — doc-page bodies keep their opaque --wh-bg fill. */ -body:has(.wh-hero) { +body:has(.wh-hero), +body:has(.wh-404) { background: transparent; } /* Layer 1 — the signal-field grid: full-viewport, continuous, soft-cornered. */ -body:has(.wh-hero)::before { +body:has(.wh-hero)::before, +body:has(.wh-404)::before { content: ""; position: fixed; inset: 0; @@ -982,7 +1182,8 @@ body:has(.wh-hero)::before { } /* Layer 2 — the hero glow: soft teal energy anchored to the top, breathing. */ -body:has(.wh-hero)::after { +body:has(.wh-hero)::after, +body:has(.wh-404)::after { content: ""; position: fixed; inset: 0; @@ -998,7 +1199,137 @@ body:has(.wh-hero)::after { to { opacity: 1.06; transform: translateY(-1.5%); } } @media (prefers-reduced-motion: reduce) { - body:has(.wh-hero)::after { animation: none; } + body:has(.wh-hero)::after, + body:has(.wh-404)::after { animation: none; } +} + +/* ---------- 404 — "signal lost" ---------- + The default 404 was a bare title + bullet list on a flat page: the one page + confused visitors land on carried the least design. This reuses the landing + atmosphere (grid + glow, the body:has additions above) under a mono 404, + a waveform that flatlines, and the card-primitive quick links. */ + +/* Hide Starlight's title panel on this page only — the custom markup carries + the visible (and accessible) h1. display:none removes the stock h1 from the + a11y tree, so the page still has exactly one heading. */ +body:has(.wh-404) .content-panel:has(> .sl-container > h1) { + display: none; +} + +.wh-404 { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 1rem; + max-width: 46rem; + margin: clamp(1.5rem, 7vh, 4.5rem) auto 2rem; +} +.wh-404__code { + margin: 0; + font-family: var(--sl-font-mono); + font-size: clamp(5rem, 3.5rem + 7vw, 8rem); + font-weight: 700; + line-height: 1; + letter-spacing: -0.05em; + font-variant-numeric: tabular-nums; + background: linear-gradient( + 180deg, + var(--wh-ink) 0%, + color-mix(in oklab, var(--wh-ink) 45%, var(--wh-ink-muted)) 100% + ); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} +.wh-404__signal { + width: min(320px, 82%); + height: auto; + margin-block: 0.25rem; +} +.wh-404__signal-live { color: var(--wh-accent-hi); } +.wh-404__signal-flat { color: var(--wh-border-strong); } +.wh-404__signal-dot { + color: var(--wh-rose); + animation: wh-404-pulse 1.6s ease-in-out infinite; +} +@keyframes wh-404-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.15; } +} +@media (prefers-reduced-motion: reduce) { + .wh-404__signal-dot { animation: none; } +} +.wh-404__title { + margin: 0; + font-size: clamp(1.75rem, 1.4rem + 1.2vw, 2.25rem); + font-weight: 700; + letter-spacing: -0.03em; +} +/* Center the accent underline the global `.content-panel > .sl-container + h1::after` rule paints — same specificity (0,2,2), later in the file wins. */ +.wh-404 h1.wh-404__title::after { + margin-inline: auto; +} +.wh-404__sub { + margin: 0; + max-width: 34rem; + color: var(--wh-ink-muted); + font-size: 1rem; + line-height: 1.6; +} +.wh-404__links { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.875rem; + width: 100%; + margin-top: 1rem; +} +@media (max-width: 34rem) { + .wh-404__links { grid-template-columns: 1fr; } +} +.wh-404__links a { + display: flex; + flex-direction: column; + gap: 0.3rem; + padding: 1rem 1.15rem; + background: var(--wh-surface); + border: 1px solid var(--wh-border); + border-radius: var(--radius-md); + text-decoration: none; + text-align: left; + transition: border-color 0.15s ease, transform 0.15s ease, + box-shadow 0.15s ease; +} +.wh-404__links a:hover { + border-color: var(--wh-accent); + transform: translateY(-2px); + box-shadow: var(--wh-shadow-glow); +} +.wh-404__links strong { + color: var(--wh-ink); + font-weight: 650; + letter-spacing: -0.01em; +} +.wh-404__links span { + color: var(--wh-ink-muted); + font-size: 0.875rem; + line-height: 1.45; +} +.wh-404__report { + margin: 0.75rem 0 0; + color: var(--wh-ink-subtle); + font-size: 0.875rem; +} +/* .not-content opts out of the markdown link styling — restate it here. */ +.wh-404__report a { + color: var(--wh-accent-text); + text-decoration: underline; + text-decoration-color: color-mix(in oklab, var(--wh-accent-text) 35%, transparent); + text-underline-offset: 3px; +} +.wh-404__report a:hover { + text-decoration-color: var(--wh-accent-text); } .wh-pill { From ec91d90683d4fbdebdc78c561b68d5ea461dcc09 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Tue, 9 Jun 2026 22:29:42 -0400 Subject: [PATCH 02/23] docs(changelog): record the docs-site polish pass under Unreleased MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding from the pre-push code reviewer: the new transition choreography, ProgressBar component, branded chrome, 404 redesign, and PostHog capture_exceptions had no [Unreleased] entry (AGENTS.md §Documentation Sync). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25cd275b..02a60aa9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Docs site polish: direction-aware page transitions, a navigation progress bar, branded search/asides/404, and client-side error tracking** (`docs/src/components/{Head,PostHog,ProgressBar (new)}.astro`, `docs/src/content/docs/404.md`, `docs/src/styles/global.css`): builds on the astro-vtbot SPA integration above. Navigating between doc pages now plays a 150ms slide whose direction follows sidebar order (vtbot `` — next page slides forward, previous back), the page title morphs between pages (a `view-transition-name` on the title-panel `h1`, direct-child-scoped so a markdown `# h1` can never duplicate the name and void the transition), and `` removes the fake-scroll artifact when navigating away from a scrolled page; a reduced-motion media query zeroes all view-transition animations as a belt-and-braces guard alongside the router's own handling. Slow navigations get a thin accent progress bar — a new self-hosted `ProgressBar.astro` on vtbot's `loading()` hook, because vtbot's own `` imports Swup's plugin from unpkg.com at runtime in every visitor's browser (a third-party CDN script in prod, against the repo's supply-chain bar; verified present in the built bundle before replacing). The search dialog — previously stock Pagefind chrome — now sits on the design system (surface/border/radius frame, code-surface input with accent focus, card-primitive result groups, accent `` hits, blurred backdrop), as do asides (the stock blue/purple/orange/red tokens were never remapped, so the first `:::note` authored would have landed off-palette), prose `kbd`/`mark`, heading-anchor hover, inner-surface scrollbars, and the mobile menu button (stock inverted to a white circle in dark mode). The 404 page replaces its bare title + bullet list with a "signal lost" treatment: the landing's grid + glow atmosphere, a mono 404, a flatlining waveform, and the card-style quick links — its custom `h1` replaces the hidden stock title panel so the page keeps exactly one heading. PostHog gains `capture_exceptions: true`, surfacing broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in error tracking; the SPA pageview capture (`capture_pageview: 'history_change'`) was verified against vtbot's lifecycle docs and is unchanged. - **Docs "Last updated" footer shows each page's real last-edit date, and robots.txt declares open AI content signals** (`.github/workflows/ci.yml`, `docs/public/robots.txt`): Starlight's `lastUpdated` reads git history, but CI checked out with the default shallow (depth-1) clone, so every page silently fell back to the build time — the live site showed the same "Last updated" on every page regardless of when it actually changed. CI now checks out with `fetch-depth: 0`. Separately, `robots.txt` declares open Content-Signals (`search=yes, ai-input=yes, ai-train=yes`): WaveHouse is open source, so agents are welcome to read, index, answer from, and train on these docs. (A markdown-twin `Link` header — the other half of the agent-discoverability pass — is being upstreamed to [`cloudflare-md-router`](https://github.com/Wave-RF/cloudflare-md-router) instead, where the twin concept lives.) - **Mermaid diagrams are click-to-zoom, with a top-level house style to author them vertically** (`docs/src/components/MermaidZoom.astro` (new), `docs/src/components/Footer.astro`, `docs/src/styles/global.css`, `AGENTS.md`): wide `flowchart LR` diagrams shrink to ~36% in the content column and are hard to read inline. Clicking any diagram now opens it in a lightbox — small diagrams scale up to fill the viewport (capped 2.5×), wide ones show at natural size with pan-scroll for full detail; Esc / backdrop / button closes, keyboard-focusable with Enter. View-transition-safe (one delegated `document` listener guarded by a window flag, re-enhanced on `astro:page-load`). The clone is re-id'd so its id-scoped inline ` diff --git a/docs/src/content/docs/404.md b/docs/src/content/docs/404.md index 191b3606..36e006ca 100644 --- a/docs/src/content/docs/404.md +++ b/docs/src/content/docs/404.md @@ -19,8 +19,22 @@ head:

Signal lost

diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 90ed1cc2..bdc464ea 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -987,11 +987,34 @@ site-search .pagefind-ui__message { letter-spacing: 0.08em; font-weight: 600; } -/* Result groups read as cards — same primitive as link cards everywhere else. */ +/* Result groups read as cards — same primitive as link cards everywhere + else. Starlight paints the group-title row and each nested row with + --sl-color-black (the page bg), which left the groups reading as stripes + on the card: instead, the title row gets a header band, nested rows sit on + the card surface, and the row hover trades Starlight's bare bright-accent + outline for a surface tint + quiet accent ring. --sl-search-corners keeps + Starlight's inner row rounding in sync with the card radius. */ +site-search { + --sl-search-corners: calc(var(--radius-md) - 1px); +} site-search .pagefind-ui__result { - background: var(--wh-surface-elevated); + background: var(--wh-surface); border: 1px solid var(--wh-border); border-radius: var(--radius-md); + overflow: hidden; +} +site-search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)) { + background-color: var(--wh-surface-elevated); +} +site-search .pagefind-ui__result-nested { + background-color: transparent; +} +site-search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):hover, +site-search .pagefind-ui__result-title:not(:where(.pagefind-ui__result-nested *)):focus-within, +site-search .pagefind-ui__result-nested:hover, +site-search .pagefind-ui__result-nested:focus-within { + outline: 1px solid var(--wh-accent); + background-color: var(--wh-surface-overlay); } site-search .pagefind-ui__result-link { color: var(--wh-ink); @@ -1009,6 +1032,38 @@ site-search .pagefind-ui__result mark { border-radius: 2px; padding-inline: 1px; } +/* The × clear control inside the input (Starlight masks it to an icon). */ +site-search .pagefind-ui__search-clear { + color: var(--wh-ink-subtle); +} +site-search .pagefind-ui__search-clear:hover { + color: var(--wh-accent-text); +} +/* "Load more results" — the secondary-button language. */ +site-search .pagefind-ui__button { + background: var(--wh-surface); + border: 1px solid var(--wh-border-strong); + border-radius: var(--radius-sm); + color: var(--wh-ink); + font-weight: 600; + transition: border-color 0.15s ease, background 0.15s ease; +} +site-search .pagefind-ui__button:hover { + border-color: var(--wh-accent); + background: var(--wh-surface-elevated); +} +/* Gentle pop on open; skipped for reduced-motion users. */ +@media (prefers-reduced-motion: no-preference) { + site-search dialog[open] { + animation: wh-search-pop 0.18s cubic-bezier(0.21, 0.61, 0.35, 1); + } +} +@keyframes wh-search-pop { + from { + opacity: 0; + transform: translateY(6px) scale(0.985); + } +} /* ---------- Asides — brand the four variants ---------- Starlight's defaults run on its stock blue/purple/orange/red tokens, which @@ -1093,10 +1148,7 @@ starlight-menu-button button[aria-expanded="true"] { box-shadow: 0 0 0 1px var(--wh-border-strong); } -/* ---------- View transitions (astro-vtbot SPA mode) ---------- - The animated element is Starlight's
— Head.astro's directives are - teleported onto it (transition:name="main" + a 150ms slide, direction-aware - via ). Everything here styles the supporting cast. */ +/* ---------- View transitions (astro-vtbot SPA mode) ---------- */ /* Navigation progress bar (src/components/ProgressBar.astro drives width/ opacity inline; everything visual lives here). Accent line over the glass @@ -1114,19 +1166,6 @@ starlight-menu-button button[aria-expanded="true"] { pointer-events: none; } -/* Page-title morph: doc titles cross-fade/glide into each other instead of - sliding away with the content — the one element a reader visually tracks - between pages. The direct-child chain targets ONLY Starlight's title panel - (one per page); a markdown `# h1` or the 404's custom title must never - match, since a duplicated view-transition-name voids the whole transition. - The landing hero title deliberately stays inside the main slide. - width:fit-content keeps the morph snapshot hugging the text (vtbag's tip) — - a full-width snapshot stretches the short→long title morph. */ -.content-panel > .sl-container > h1 { - view-transition-name: page-title; - width: fit-content; -} - /* Belt-and-braces: no transition choreography for reduced-motion users, even if a future router/browser change stops handling it upstream. */ @media (prefers-reduced-motion: reduce) { @@ -1243,21 +1282,53 @@ body:has(.wh-404) .content-panel:has(> .sl-container > h1) { color: transparent; } .wh-404__signal { - width: min(320px, 82%); + width: min(380px, 86%); height: auto; margin-block: 0.25rem; + overflow: visible; /* the trace's glow shouldn't clip at the viewBox edge */ +} +/* The trace: one path, heartbeat → dying blips → flatline, colored by a + left-to-right gradient (live accent fading into dead border-gray). A dim + static ghost of the full trace sits underneath so the shape always reads; + the bright copy on top is redrawn like a monitor scan: pathLength="1" + normalizes the dash units, and sliding a full-length dash from +1 to -1 + draws the line in from the left, then erases it behind itself — seamless + loop. The declared dashoffset: 0 is the no-animation state (full static + trace), which is exactly what reduced-motion users get when the animation + is removed (the redundant ghost is hidden there). */ +.wh-404__signal-ghost { + opacity: 0.22; } -.wh-404__signal-live { color: var(--wh-accent-hi); } -.wh-404__signal-flat { color: var(--wh-border-strong); } +@media (prefers-reduced-motion: reduce) { + .wh-404__signal-ghost { display: none; } +} +.wh-404__signal-path { + stroke-dasharray: 1 1; + stroke-dashoffset: 0; + animation: wh-404-sweep 3.4s linear infinite; + filter: drop-shadow(0 0 6px var(--wh-accent-glow-strong)); +} +@keyframes wh-404-sweep { + from { stroke-dashoffset: 1; } + to { stroke-dashoffset: -1; } +} +.wh-404__stop-live { stop-color: var(--wh-accent-hi); } +.wh-404__stop-mid { stop-color: var(--wh-accent); } +.wh-404__stop-dead { stop-color: var(--wh-border-strong); } +/* The monitor's "current sample" dot at the end of the flatline, blinking + out of phase with the sweep so the page never looks fully still. */ .wh-404__signal-dot { color: var(--wh-rose); - animation: wh-404-pulse 1.6s ease-in-out infinite; + animation: wh-404-pulse 1.7s ease-in-out infinite; + filter: drop-shadow(0 0 5px currentColor); } @keyframes wh-404-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.15; } + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.2; transform: scale(0.7); } } +.wh-404__signal-dot { transform-origin: 306px 24px; } @media (prefers-reduced-motion: reduce) { + .wh-404__signal-path, .wh-404__signal-dot { animation: none; } } .wh-404__title { From f6cd655153422e237bc1fd2ed589c272ee79f01b Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Tue, 9 Jun 2026 23:20:33 -0400 Subject: [PATCH 04/23] fix(docs): subtler content-aligned reading bar, search scrollbar, h2 rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR preview feedback round 3: - Reading bar: 2px solid accent at 75% opacity (was a 2.5px glowing gradient), spanning only the content column — offset past the sidebar at >=50rem, hidden >=72rem where the right-rail TOC already answers "where am I". Fixes the line crossing the sidebar border and the unexpected full-viewport width. - Search dialog: the results list scrolls inside .dialog-frame with a stock chunky UA scrollbar against the rounded corner — now thin and brand-colored (dialog + frame). - Doc h2 section rule: the full-width border-bottom boxed every section against the content edge; replaced with a hairline that fades out to the right (same scannability, no grid-paper feel). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/src/components/ReadingProgress.astro | 19 ++++++++++++++++--- docs/src/styles/global.css | 14 +++++++++++++- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb63249..d2c42166 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Docs site polish: navigation + reading progress bars, branded search/asides/404, and client-side error tracking** (`docs/src/components/{Head,Footer,PostHog,ProgressBar (new),ReadingProgress (new)}.astro`, `docs/src/content/docs/404.md`, `docs/src/styles/global.css`): builds on the astro-vtbot SPA integration above. Slow navigations get a thin accent progress bar — a new self-hosted `ProgressBar.astro` on vtbot's `loading()` hook, because vtbot's own `` imports Swup's plugin from unpkg.com at runtime in every visitor's browser (a third-party CDN script in prod, against the repo's supply-chain bar; verified present in the built bundle before replacing). Doc pages additionally get a reading-progress bar (`ReadingProgress.astro`, rendered from the sidebar footer branch): a 2.5px accent line under the header filling with the scrolled-past fraction of the article body — the "how far through this page am I" signal the right-rail TOC gives desktop but mobile had nothing for; it ignores footer chrome, hides on articles shorter than the viewport and under the open mobile menu, and updates via one rAF-throttled passive listener that survives view transitions. (A direction-aware page slide + title morph were also tried here and ripped out on review — too much motion for docs; a reduced-motion media query zeroing all view-transition animations stays as a belt-and-braces guard.) The search dialog — previously stock Pagefind chrome — now sits on the design system (surface/border/radius frame with a gentle reduced-motion-aware pop, code-surface input with accent focus, card result groups with a header band + surface-tint row hover replacing Starlight's bare bright outline, accent `` hits, branded clear/load-more controls, blurred backdrop), as do asides (the stock blue/purple/orange/red tokens were never remapped, so the first `:::note` authored would have landed off-palette), prose `kbd`/`mark`, heading-anchor hover, inner-surface scrollbars, and the mobile menu button (stock inverted to a white circle in dark mode). The 404 page replaces its bare title + bullet list with a "signal lost" treatment: the landing's grid + glow atmosphere, a mono 404, an EKG-style trace (heartbeat decaying into a flatline, drawn by a looping monitor-sweep dash animation over a live→dead gradient, with a blinking sample dot; static full trace for reduced motion), and the card-style quick links — its custom `h1` replaces the hidden stock title panel so the page keeps exactly one heading. PostHog gains `capture_exceptions: true`, surfacing broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in error tracking; the SPA pageview capture (`capture_pageview: 'history_change'`) was verified against vtbot's lifecycle docs and is unchanged. +- **Docs site polish: navigation + reading progress bars, branded search/asides/404, and client-side error tracking** (`docs/src/components/{Head,Footer,PostHog,ProgressBar (new),ReadingProgress (new)}.astro`, `docs/src/content/docs/404.md`, `docs/src/styles/global.css`): builds on the astro-vtbot SPA integration above. Slow navigations get a thin accent progress bar — a new self-hosted `ProgressBar.astro` on vtbot's `loading()` hook, because vtbot's own `` imports Swup's plugin from unpkg.com at runtime in every visitor's browser (a third-party CDN script in prod, against the repo's supply-chain bar; verified present in the built bundle before replacing). Doc pages additionally get a reading-progress bar (`ReadingProgress.astro`, rendered from the sidebar footer branch): a 2px accent line under the header filling with the scrolled-past fraction of the article body — the "how far through this page am I" signal the right-rail TOC gives desktop but mobile had nothing for; it spans only the content column (offset past the sidebar once that exists, hidden entirely ≥72rem where the TOC takes over), ignores footer chrome, hides on articles shorter than the viewport and under the open mobile menu, and updates via one rAF-throttled passive listener that survives view transitions. (A direction-aware page slide + title morph were also tried here and ripped out on review — too much motion for docs; a reduced-motion media query zeroing all view-transition animations stays as a belt-and-braces guard.) The search dialog — previously stock Pagefind chrome — now sits on the design system (surface/border/radius frame with a gentle reduced-motion-aware pop, code-surface input with accent focus, card result groups with a header band + surface-tint row hover replacing Starlight's bare bright outline, accent `` hits, branded clear/load-more controls, blurred backdrop), as do asides (the stock blue/purple/orange/red tokens were never remapped, so the first `:::note` authored would have landed off-palette), prose `kbd`/`mark`, heading-anchor hover, inner-surface scrollbars, and the mobile menu button (stock inverted to a white circle in dark mode). The 404 page replaces its bare title + bullet list with a "signal lost" treatment: the landing's grid + glow atmosphere, a mono 404, an EKG-style trace (heartbeat decaying into a flatline, drawn by a looping monitor-sweep dash animation over a live→dead gradient, with a blinking sample dot; static full trace for reduced motion), and the card-style quick links — its custom `h1` replaces the hidden stock title panel so the page keeps exactly one heading. PostHog gains `capture_exceptions: true`, surfacing broken docs JS (LiveDemo SSE, copy buttons, theme scripts) in error tracking; the SPA pageview capture (`capture_pageview: 'history_change'`) was verified against vtbot's lifecycle docs and is unchanged. - **Docs "Last updated" footer shows each page's real last-edit date, and robots.txt declares open AI content signals** (`.github/workflows/ci.yml`, `docs/public/robots.txt`): Starlight's `lastUpdated` reads git history, but CI checked out with the default shallow (depth-1) clone, so every page silently fell back to the build time — the live site showed the same "Last updated" on every page regardless of when it actually changed. CI now checks out with `fetch-depth: 0`. Separately, `robots.txt` declares open Content-Signals (`search=yes, ai-input=yes, ai-train=yes`): WaveHouse is open source, so agents are welcome to read, index, answer from, and train on these docs. (A markdown-twin `Link` header — the other half of the agent-discoverability pass — is being upstreamed to [`cloudflare-md-router`](https://github.com/Wave-RF/cloudflare-md-router) instead, where the twin concept lives.) - **Mermaid diagrams are click-to-zoom, with a top-level house style to author them vertically** (`docs/src/components/MermaidZoom.astro` (new), `docs/src/components/Footer.astro`, `docs/src/styles/global.css`, `AGENTS.md`): wide `flowchart LR` diagrams shrink to ~36% in the content column and are hard to read inline. Clicking any diagram now opens it in a lightbox — small diagrams scale up to fill the viewport (capped 2.5×), wide ones show at natural size with pan-scroll for full detail; Esc / backdrop / button closes, keyboard-focusable with Enter. View-transition-safe (one delegated `document` listener guarded by a window flag, re-enhanced on `astro:page-load`). The clone is re-id'd so its id-scoped inline ` diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 9fe4c486..e67da11b 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -584,32 +584,46 @@ svg[aria-roledescription^="flowchart"] .cluster rect { /* ---------- Starlight tweaks ---------- */ /* Generous heading spacing — keeps the deck-style rhythm from the - * previous design but with crisper headings. */ -.sl-markdown-content :is(h2, h3, h4) { + * previous design but with crisper headings. + * + * Starlight wraps every content heading in .sl-heading-wrapper and renders + * the H tag itself display:INLINE (so the anchor link flows after the text). + * Inline boxes fragment per line: backgrounds/borders/padding repeat on each + * wrapped line (the mobile "strikethrough through wrapped headings" bug — + * which the old border-bottom had too), and block margins no-op entirely. + * So rhythm, padding, and the section rule live on the WRAPPER block; only + * type styles stay on the heading element. */ +.sl-markdown-content :is(h2, h3, h4), +.sl-markdown-content .sl-heading-wrapper { scroll-margin-top: 5rem; } .sl-markdown-content h1 { font-weight: 700; letter-spacing: -0.03em; } -.sl-markdown-content h2 { +.sl-markdown-content .sl-heading-wrapper.level-h2 { margin-top: 4rem; /* one clean section step (was 3.5rem off-scale) */ - font-weight: 650; - letter-spacing: -0.025em; padding-bottom: 0.5rem; - /* Section rule, but fading out to the right — the old full-width - border-bottom slammed into the content edge and boxed every section; - the fade keeps the scannability without the grid-paper feel. */ - border-bottom: 0; + /* Section rule, but fading out to the right — a full-width border-bottom + slammed into the content edge and boxed every section; the fade keeps + the scannability without the grid-paper feel. */ background: linear-gradient(90deg, var(--wh-border-strong), transparent 82%) bottom left / 100% 1px no-repeat; } -.sl-markdown-content h3 { +.sl-markdown-content h2 { + font-weight: 650; + letter-spacing: -0.025em; +} +.sl-markdown-content .sl-heading-wrapper.level-h3 { margin-top: 2.5rem; +} +.sl-markdown-content h3 { font-weight: 600; } -.sl-markdown-content h4 { +.sl-markdown-content .sl-heading-wrapper.level-h4 { margin-top: 2rem; +} +.sl-markdown-content h4 { font-weight: 600; } .sl-markdown-content p { @@ -644,6 +658,16 @@ svg[aria-roledescription^="flowchart"] .cluster rect { color: var(--wh-ink); } +/* No ligatures anywhere code is rendered: JetBrains Mono rewrites `!=` as ≠, + `>=` as ≥, `<=` as ≤ — in reference tables documenting the EXACT characters + an API accepts, that's misinformation. */ +code, +kbd, +samp, +.expressive-code { + font-variant-ligatures: none; +} + /* macOS-style colored dots for terminal code blocks — kept from prior design */ .expressive-code .frame.is-terminal figcaption.header::before { background: #ff5f57 !important; @@ -702,13 +726,28 @@ svg[aria-roledescription^="flowchart"] .cluster rect { /* Top nav — translucent glass over content (only when scrolled past 0). Inherits the same opacity as --sl-color-bg-nav so the design-system token - stays the single source of truth (avoids the prior 72%/78% drift). */ + stays the single source of truth (avoids the prior 72%/78% drift). + Standard property ONLY — a hand-written -webkit- twin makes LightningCSS + treat prefixing as author-managed and DROP the standard declaration from + the build (Firefox then loses the blur entirely). */ header.header { backdrop-filter: blur(12px) saturate(140%); - -webkit-backdrop-filter: blur(12px) saturate(140%); background: var(--sl-color-bg-nav); border-bottom: 1px solid var(--wh-border); } +/* Mobile: glass off. A translucent fixed bar over a small screen reads as a + double exposure while scrolling, and Starlight's sticky "On this page" bar + shares the translucent token with no blur at all — both go opaque. */ +@media (max-width: 50rem) { + header.header { + background: var(--wh-bg); + backdrop-filter: none; + } + mobile-starlight-toc nav { + background-color: var(--wh-bg); + border-bottom: 1px solid var(--wh-border); + } +} /* Right-rail TOC — quieter and monospace-y */ .right-sidebar-panel h2 { @@ -872,29 +911,35 @@ a.sl-link-button.minimal:hover { } /* Tables — card container + branded header (was a bare HTML table: no - container, no header fill, hairline rows). */ + container, no header fill, hairline rows). + Layout = GitHub's scroll-don't-crush pattern: the table sizes its columns + naturally (auto layout), caps at the content column, and becomes its own + horizontal scroller when the columns genuinely can't fit. The previous + fixed-layout + overflow-wrap:anywhere treatment kept tables inside the + column by SHREDDING content instead — at 390px a 4-column reference table + wrapped every identifier 4 characters at a time ("WH_P/ROME/THEU/S_PO/RT"), + and even at 1440px fixed equal columns split tokens mid-word. */ .sl-markdown-content table { - display: table; - width: 100%; - /* Fixed layout + cell wrapping (below) guarantees the table fits its column - instead of growing to min-content and overflowing the page on dense - tables (e.g. configuration's 4-column env-var table). */ - table-layout: fixed; + display: block; + width: max-content; + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: var(--wh-border-strong) transparent; border: 1px solid var(--wh-border); border-radius: var(--radius-md); border-collapse: separate; border-spacing: 0; - overflow: hidden; } .sl-markdown-content :is(th, td) { border: 0; border-bottom: 1px solid var(--wh-border); padding: 0.55rem 0.85rem; - /* Let long unbreakable tokens (env-var names like WH_SERVER_CORS_ALLOWED_ - ORIGINS, defaults) wrap so a dense table can't force itself wider than the - content column and overflow the page — width:100% + overflow:hidden alone - can't shrink non-wrapping cells. */ - overflow-wrap: anywhere; + /* Prose wraps at spaces when the table is width-capped; unbreakable code + tokens set the column's min width and trigger the scroll instead of + splitting mid-token. */ + overflow-wrap: normal; } .sl-markdown-content thead th { background: var(--wh-surface-elevated); @@ -908,13 +953,6 @@ a.sl-link-button.minimal:hover { .sl-markdown-content thead th code { text-transform: none; } -/* Fixed layout distributes columns equally; give the last column (the prose - "description / meaning" in the docs' reference tables) extra room so the - content-heavy column isn't squeezed into a tall ribbon next to half-empty - key/default columns. */ -.sl-markdown-content table :is(th, td):last-child { - width: 42%; -} .sl-markdown-content tbody tr:last-child :is(th, td) { border-bottom: 0; } @@ -977,10 +1015,10 @@ site-search .dialog-frame { } /* Bottom scrim on the scroller: rows used to hard-clip mid-glyph against the dialog's rounded bottom edge; the fade makes the cut read as intentional. - Bottom-only, so the pinned input at the top of the frame is unaffected. */ + Bottom-only, so the pinned input at the top of the frame is unaffected. + (Standard property only — the build pipeline emits the -webkit- twin.) */ site-search .dialog-frame { - mask-image: linear-gradient(180deg, #000 calc(100% - 14px), transparent); - -webkit-mask-image: linear-gradient(180deg, #000 calc(100% - 14px), transparent); + mask-image: linear-gradient(180deg, #000 calc(100% - 26px), transparent); } /* The input: code-surface + square radius to match the header trigger (the stock pill was the lone fully-round control on the site). Pagefind paints From 72cb96bce6263309e4c682bb1953e8a63812c283 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Wed, 10 Jun 2026 02:16:29 -0400 Subject: [PATCH 12/23] =?UTF-8?q?fix(docs):=20verification-round=20residua?= =?UTF-8?q?ls=20=E2=80=94=20table=20floors,=20natural-size=20diagrams,=20a?= =?UTF-8?q?side=20titles,=20state-machine=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second 15-agent audit over a regenerated corpus (mechanical overflow: 0 hits site-wide with the corrected detector). Confirmed fixed: chrome opacity, table shredding, heading rules, scroll affordances. Residuals fixed here: - Scrolling tables: per-column min-widths (6rem, last column min(26rem, 75vw)). Without floors auto layout crushed off-screen prose columns to ~100px — their 10-20 wrapped lines drove row height, leaving near-blank viewport-tall rows (config policy table was a full-screen empty frame at 390; tallest row now 438px). - Table code chips: white-space nowrap — `--url` broke at hyphens into what read as two separate flags (sdk codegen table). - Diagrams pin to Mermaid's stamped natural width inside the scroller (style.minWidth = style.maxWidth): the 540px CSS floor still let the widest ingest-pipeline diagrams shrink ~33% to ~5px labels. Verified 2148px natural = 2148px rendered at 390. - Aside titles: block flow + inline icon — Starlight's flex title made a trailing code chip its own flex item, detaching it from the sentence when the title wrapped. - Zoom lightbox stage joins the data-wh-more fade contract (it hard-clipped nodes at its edge with no scroll hint). - ingest-pipeline state-machine edge labels shortened; the long labels overlapped each other at every width (prose explains the semantics). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/src/components/MermaidZoom.astro | 8 +++++++ docs/src/components/ScrollHints.astro | 29 +++++++++++++++++++++++- docs/src/content/docs/ingest-pipeline.md | 16 ++++++------- docs/src/styles/global.css | 12 ++++++++++ 5 files changed, 57 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a05160df..272f1a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Docs mobile rendering overhaul — tables, diagrams, chrome, and wrapped headings no longer break at phone widths** (`docs/src/styles/global.css`, `docs/src/components/{ScrollHints (new),MermaidZoom,Footer,ReadingProgress}.astro`): a 15-agent visual audit of every page × 320–1440px × both themes (driven by the PR author's report that "many pages look broken on mobile") found four systemic defects, all shipped-broken before this branch and some made worse by it. (1) **Tables crushed instead of scrolling**: the fixed-layout + `overflow-wrap: anywhere` treatment kept dense tables inside the column by shredding content — at 390px reference tables wrapped identifiers a few characters at a time (`WH_P/ROME/THEU/S_PO/RT`), and even at 1440px fixed equal columns split tokens mid-word. Tables now scroll instead of crushing: a JS wrapper (`ScrollHints.astro`) gives each table a framed scroll container with true auto layout (prose wraps first, scroll only when unbreakable columns can't fit), with a GitHub-style `display:block` scroll fallback when JS is off. (2) **Mermaid diagrams scaled to thumbnails on phones**: standalone diagrams shrank to fit the column (≈3–7px labels — unreadable); they're now wrapped in the same scroller and keep a readable size with horizontal pan (no-JS keeps scale-to-fit), the zoom lightbox uses `safe` centering so a natural-size diagram's left/top edge is reachable on phones (plain centering made overflow start-side unscrollable), and coarse-pointer devices get a visible ⤢ zoom affordance (cursor: zoom-in means nothing on touch). (3) **Translucent fixed chrome double-exposed on mobile**: the glass header and Starlight's sticky "On this page" bar ghosted scrolled content through themselves; both are opaque below 50rem (glass stays on desktop), and the hand-written `-webkit-backdrop-filter` twin was removed because it made LightningCSS treat prefixing as author-managed and drop the standard `backdrop-filter` from the build entirely (Firefox had no blur). (4) **Wrapped headings drew their section rule through the text**: Starlight renders content headings `display: inline` inside `.sl-heading-wrapper` (for the anchor link), so backgrounds/borders fragment per line — the h2 rule struck through every wrapped line on mobile (the pre-branch `border-bottom` had the same bug) and heading block margins silently no-oped; rhythm + rule now live on the wrapper. Plus: every horizontal scroller (code blocks, tables, diagrams) gets a right-edge fade scrim while more content is hidden (cut-off code read as truncated, not scrollable), code ligatures are disabled site-wide (`!=` rendered as `≠` in tables documenting exact operator syntax), the footer keeps three columns at tablet widths, and the reading-progress bar regains visibility (0.9 opacity). +- **Docs mobile rendering overhaul — tables, diagrams, chrome, and wrapped headings no longer break at phone widths** (`docs/src/styles/global.css`, `docs/src/components/{ScrollHints (new),MermaidZoom,Footer,ReadingProgress}.astro`): a 15-agent visual audit of every page × 320–1440px × both themes (driven by the PR author's report that "many pages look broken on mobile") found four systemic defects, all shipped-broken before this branch and some made worse by it. (1) **Tables crushed instead of scrolling**: the fixed-layout + `overflow-wrap: anywhere` treatment kept dense tables inside the column by shredding content — at 390px reference tables wrapped identifiers a few characters at a time (`WH_P/ROME/THEU/S_PO/RT`), and even at 1440px fixed equal columns split tokens mid-word. Tables now scroll instead of crushing: a JS wrapper (`ScrollHints.astro`) gives each table a framed scroll container with true auto layout (prose wraps first, scroll only when unbreakable columns can't fit), with a GitHub-style `display:block` scroll fallback when JS is off. (2) **Mermaid diagrams scaled to thumbnails on phones**: standalone diagrams shrank to fit the column (≈3–7px labels — unreadable); they're now wrapped in the same scroller and keep a readable size with horizontal pan (no-JS keeps scale-to-fit), the zoom lightbox uses `safe` centering so a natural-size diagram's left/top edge is reachable on phones (plain centering made overflow start-side unscrollable), and coarse-pointer devices get a visible ⤢ zoom affordance (cursor: zoom-in means nothing on touch). (3) **Translucent fixed chrome double-exposed on mobile**: the glass header and Starlight's sticky "On this page" bar ghosted scrolled content through themselves; both are opaque below 50rem (glass stays on desktop), and the hand-written `-webkit-backdrop-filter` twin was removed because it made LightningCSS treat prefixing as author-managed and drop the standard `backdrop-filter` from the build entirely (Firefox had no blur). (4) **Wrapped headings drew their section rule through the text**: Starlight renders content headings `display: inline` inside `.sl-heading-wrapper` (for the anchor link), so backgrounds/borders fragment per line — the h2 rule struck through every wrapped line on mobile (the pre-branch `border-bottom` had the same bug) and heading block margins silently no-oped; rhythm + rule now live on the wrapper. Plus: every horizontal scroller (code blocks, tables, diagrams, the zoom lightbox's pannable stage) gets a right-edge fade scrim while more content is hidden (cut-off code read as truncated, not scrollable), code ligatures are disabled site-wide (`!=` rendered as `≠` in tables documenting exact operator syntax), the footer keeps three columns at tablet widths, and the reading-progress bar regains visibility (0.9 opacity). A second 15-agent verification round over a fresh corpus then confirmed the fixes and caught the stragglers: scrolling tables gained per-column minimum widths (without floors, auto layout squeezed off-screen prose columns to ~100px whose 10–20 wrapped lines left near-blank viewport-tall rows), table code chips no longer break at hyphens (`--url` read as two flags), diagrams pin to their Mermaid-stamped natural width inside the pan scroller (a 540px floor still let the widest shrink ~33% to ~5px labels), aside titles flow as block text so a title's code chip can't detach from its sentence (Starlight's flex title made the chip its own flex item), and the ingest-pipeline state-machine's edge labels were shortened to stop dagre overlapping them at every width. - **sdk.md's Live Queries example no longer chains the query builder off `from()` directly** (`docs/src/content/docs/sdk.md`, `docs/src/content/docs/index.mdx`): `wh.from(table)` returns a `TableRef`, which has no `.where()`/`.orderBy()`/`.limit()`/`.liveQuery()` — those live on `QueryBuilder`, entered only via `.select(...)`/`.selectAll()`. The documented example failed to type-check and, copied as-is, threw `TypeError: wh.from(...).where is not a function`; it now starts the chain with `.selectAll()`, and the landing-page SDK showcase (added on this branch, which had faithfully inherited the broken pattern from this example) gets the same fix. Surfaced by the branch's pre-push review; no e2e test exercises that chain shape, which is why it had gone uncaught. diff --git a/docs/src/components/MermaidZoom.astro b/docs/src/components/MermaidZoom.astro index cdddbf17..e3a0f9a4 100644 --- a/docs/src/components/MermaidZoom.astro +++ b/docs/src/components/MermaidZoom.astro @@ -87,6 +87,14 @@ stage.appendChild(clone); overlay.removeAttribute("hidden"); + // Same right-edge fade contract as the inline scrollers (ScrollHints + // owns the [data-wh-more] CSS + the capture-phase scroll listener that + // clears/re-stamps it as the user pans) — without it the pannable stage + // hard-clipped nodes at its edge with no hint that it scrolls. + requestAnimationFrame(() => { + const more = stage.scrollWidth - stage.clientWidth - stage.scrollLeft > 3; + stage.toggleAttribute("data-wh-more", more); + }); // Next frame so the un-hide paints before the open transition. requestAnimationFrame(() => overlay.classList.add("is-open")); document.documentElement.style.overflow = "hidden"; diff --git a/docs/src/components/ScrollHints.astro b/docs/src/components/ScrollHints.astro index f3a18b9d..cf42ee9b 100644 --- a/docs/src/components/ScrollHints.astro +++ b/docs/src/components/ScrollHints.astro @@ -20,7 +20,7 @@ -``` - -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 diff --git a/docs/src/content/docs/sdk/pipes.md b/docs/src/content/docs/sdk/pipes.md index 8b95e97a..a7cb5f35 100644 --- a/docs/src/content/docs/sdk/pipes.md +++ b/docs/src/content/docs/sdk/pipes.md @@ -6,6 +6,8 @@ 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?)` diff --git a/docs/src/content/docs/sdk/queries.md b/docs/src/content/docs/sdk/queries.md index df8b14c5..26787751 100644 --- a/docs/src/content/docs/sdk/queries.md +++ b/docs/src/content/docs/sdk/queries.md @@ -7,6 +7,8 @@ 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)` diff --git a/docs/src/content/docs/sdk/streaming.md b/docs/src/content/docs/sdk/streaming.md index 3df7948f..c22c5f1a 100644 --- a/docs/src/content/docs/sdk/streaming.md +++ b/docs/src/content/docs/sdk/streaming.md @@ -6,6 +6,8 @@ description: "Real-time SSE streams, client-side filtering, and backfill-then-li 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 diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 4d481420..e141977d 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -681,6 +681,40 @@ samp, border-color: var(--wh-accent); } +/* Language badge — every code frame names its language in the bottom-right + corner (Expressive Code stamps data-language on the
). Quiet by
+   design: readers who need it find it, everyone else reads past it.
+   Terminal frames skip it (the title bar already says "shell"), as do
+   plain-text-family blocks where a label would be noise. */
+.expressive-code pre {
+  position: relative;
+}
+.expressive-code pre::after {
+  content: attr(data-language);
+  position: absolute;
+  bottom: 0.45rem;
+  right: 0.65rem;
+  font-family: var(--sl-font-mono);
+  font-size: 0.65rem;
+  font-weight: 600;
+  line-height: 1;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--wh-ink-subtle);
+  opacity: 0.75;
+  pointer-events: none;
+  user-select: none;
+}
+.expressive-code .frame.is-terminal pre::after,
+.expressive-code pre:is(
+    [data-language="text"],
+    [data-language="plaintext"],
+    [data-language="txt"],
+    [data-language="ansi"]
+  )::after {
+  content: none;
+}
+
 /* Sidebar: group-header eyebrows, branded hover, accent current item.
    (The previous `.sidebar-content nav > ul …` selector matched 0 elements —
    the DOM is `nav.sidebar > .sidebar-content`, no nested nav — so the intended
@@ -711,6 +745,14 @@ samp,
   border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
   padding-left: calc(var(--sl-sidebar-item-padding-inline) - 2px);
 }
+/* External sidebar links (GitHub guide/discussions/etc.) declare themselves —
+   sidebar.ts stamps target="_blank" on every off-site entry. */
+.sidebar-content a[target="_blank"]::after {
+  content: "↗";
+  margin-inline-start: 0.35em;
+  font-size: 0.75em;
+  opacity: 0.55;
+}
 
 /* Top nav — translucent glass over content (only when scrolled past 0).
    Inherits the same opacity as --sl-color-bg-nav so the design-system token
@@ -735,6 +777,18 @@ header.header {
     background-color: var(--wh-bg);
     border-bottom: 1px solid var(--wh-border);
   }
+  /* Icon-only search trigger: the desktop rule's bordered SQUARE surface has
+     no radius at this size — join the menu button's circular control family
+     (same surface, same hairline ring). */
+  site-search > button {
+    border: 0;
+    border-radius: 999px;
+    background-color: var(--wh-surface);
+    box-shadow: 0 0 0 1px var(--wh-border);
+  }
+  site-search > button:hover {
+    box-shadow: 0 0 0 1px var(--wh-accent);
+  }
 }
 
 /* Right-rail TOC — quieter and monospace-y */
@@ -909,20 +963,55 @@ a.sl-link-button.minimal:hover {
   .sl-markdown-content > * {
     grid-column: 2;
   }
-  /* The breakout set: scroller-wrapped tables/diagrams, code frames, and
-     paired diagrams. fit-content sizes each to what its content actually
-     needs — a short code block or narrow table stays at the prose width
+  /* Code frames + paired diagrams: fit-content sizes each to what its
+     content actually needs — a short code block stays at the prose width
      (min-width keeps its edges aligned with the text), a wide one grows up
      to the pane. Centered growth, symmetric margins. */
-  /* `table` covers the no-JS fallback (before ScrollHints wraps tables, they
-     are direct children using the display:block scroll pattern). */
-  .sl-markdown-content > :is(.wh-scroll-x, .expressive-code, .diagram-pair, table) {
+  .sl-markdown-content > :is(.expressive-code, .diagram-pair) {
     grid-column: 1 / -1;
     justify-self: center;
     width: fit-content;
     min-width: min(var(--sl-content-width), 100%);
     max-width: 100%;
   }
+  /* Diagram scrollers need an EXPLICIT width: fit-content can't measure a
+     `width:100%` SVG child (it resolved to the min-width floor and pinned
+     2000px-natural diagrams at prose width). min(natural, pane) is the
+     intelligent default — a small diagram stays its natural size, a wide
+     one takes the full pane before scale-down kicks in. */
+  .sl-markdown-content > .wh-scroll-x:not(.wh-table-wrap) {
+    grid-column: 1 / -1;
+    justify-self: center;
+    width: min(var(--wh-natural-w, 100%), 100%);
+    max-width: 100%;
+  }
+  /* Same sizing for nested diagram wrappers (the .diagram-pair children).
+     The pair CONTAINER is excluded: it can host multiple diagrams, so a
+     single child's natural width doesn't describe it — it keeps the
+     fit-content sizing from the generic rule above. */
+  .sl-markdown-content .wh-scroll-x:not(.wh-table-wrap):not(.diagram-pair) {
+    width: min(var(--wh-natural-w, 100%), 100%);
+    margin-inline: auto;
+  }
+  /* Tables EARN their breakout (data-wh-wide, stamped by ScrollHints only
+     when the table overflows or crowds at prose width): a table whose prose
+     wraps fine at 45rem gains nothing from stretching its sentences to
+     unreadable line lengths, so it stays put. Wide tables cap below the
+     pane so they keep breathing room from the sidebar/TOC rails. */
+  .sl-markdown-content > .wh-table-wrap[data-wh-wide] {
+    grid-column: 1 / -1;
+    justify-self: center;
+    width: fit-content;
+    min-width: min(var(--sl-content-width), 100%);
+    max-width: min(calc(100% - 2.5rem), 68rem);
+  }
+  /* Everything else in the un-capped container but outside the grid (the
+     page footer: edit link, last-updated, prev/next) keeps the prose width,
+     centered on the same axis as the grid's middle track. */
+  .main-pane .sl-container:has(> .sl-markdown-content) > :not(.sl-markdown-content) {
+    max-width: var(--sl-content-width);
+    margin-inline: auto;
+  }
 }
 
 /* Prev/next pagination — mirror the link-card treatment (was a flat bordered

From 09d08c2f88f40ac24f2156dc7b5ad3f7cb0bd9cb Mon Sep 17 00:00:00 2001
From: Eric Andrechek 
Date: Wed, 10 Jun 2026 11:36:36 -0400
Subject: [PATCH 18/23] fix(docs): consistent code frames, docked format tabs,
 natural-width tables
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Fourth response round to PR-review feedback:

- Language badge on EVERY code frame (terminals included), top-right /
  in the title bar — ScrollHints copies pre[data-language] to the frame
  (CSS attr() can't cross elements); yields to the hover copy button on
  headerless frames; plain-text frames stay unlabeled. Replaces the
  bottom-right pre badge that sat too close to the code.
- Code-format tab groups dock onto their code frame (flush edge, shared
  corner) — the floating segmented control read as disconnected.
- Every substantive policy fragment on access-control is now YAML/JSON
  tabs (7 groups, each JSON twin machine-parsed equal to its YAML).
- configuration tables back to separate YAML Key / Env Var columns —
  the stacked merge predated wide-table support; 4-col tables now ride
  the breakout track (all wide at 1920, zero scroll).
- Tables widen by natural width: max-content measured in a batched
  reflow; natural <= 1.3x the track -> widen to stop wrapping (the
  why-wavehouse matrices now use the width); prose-heavy tables keep
  the reading measure; >=4-col under ~12.5rem average also widen.
- Diagram fixes: svgs directly inside .diagram-pair get their OWN
  scroller (the pair was classed as one — shared zoom chip, shared
  natural width, clipped highlight boxes); 2px block padding stops
  overflow-y slicing mermaid cluster strokes; pair children span the
  pair on phones (page no longer scrolls sideways at 320-768).
- Zoom chip is a masked stroke icon on a translucent blurred chip (was
  a DIY-looking text glyph); fade threshold 12px (a phantom fade
  reproduced at devicePixelRatio 2 — final audit run at DPR2: zero).
- External-link markers extended to prose + footer links.
- Dropped the now-redundant "# .env" comment under the .env title tab.

Verified: 19 pages x 8 widths x DPR2 — zero page/element overflow, zero
phantom fades; all 7 policy YAML/JSON groups parse equal; config 13
tables x 4 columns wide at 1920 without scroll; pair diagrams each
carry their own chip with full cluster borders.

Co-Authored-By: Claude Fable 5 
---
 CHANGELOG.md                             |   4 +-
 docs/src/components/ScrollHints.astro    | 141 ++++++++++++++++++-----
 docs/src/content/docs/access-control.mdx | 116 +++++++++++++++++++
 docs/src/content/docs/configuration.mdx  | 120 +++++++++----------
 docs/src/content/docs/development.md     |   1 -
 docs/src/styles/global.css               |  92 ++++++++++++---
 6 files changed, 363 insertions(+), 111 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 80513a93..8563968a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -47,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - **Radius** — collapsed ~11 ad-hoc values (3.2 / 6 / 6.4 / 7.2 / 9 / 10 / 13 / 15984px …) to one scale (`--radius-xs/sm/md/lg/pill` = 4/8/12/16/999px). All buttons are now 8px, all cards/panels/code-frames/tables 12px (Expressive Code's `borderRadius` set so the frame and its inner corners share one value — no more 1px lip), chips/inline-code 4px, pills/dots 999px.
   - **Buttons** — the same CTA used to render three ways (hero "Get started" was an 8px/weight-600/#06B0BF rectangle; the closer-band one was a 56px weight-400 #1DC9D8 **pill**). Unified `a.sl-link-button` to the hero language (8px rectangle, weight 600, single accent fill); the hero's "View on GitHub" finally renders its `↗` icon (Starlight parses `icon: external` into an object, so the old string compare silently never matched); the informational stat strip no longer fake-lifts on hover (it isn't clickable). Dead `.action.*` duplicate block removed.
   - **Light theme** — split an `--wh-accent-text` role (dark `#1DC9D8`, light `#086D77`) so accent-as-text passes AA in light (was the bright `#06B0BF` at ~2.6:1); added light overrides for the status hues (`amber`/`emerald`/`rose`, previously dark-only and failing as labels); stepped the page bg one shade darker (`#FAFAF9` → `#F1F2F5`) so white cards/panels read as distinct layers (was ~1.04:1, visually flat); darkened light `--wh-ink-subtle` and lifted dark `--wh-ink-subtle`/`--wh-terracotta-text` to clear 4.5:1 on the surfaces they sit on; `:focus-visible` uses the AA-safe role and no longer squares element corners.
-  - **Layout** — settled on Starlight's stock geometry after a measured detour: an earlier round widened the content track in steps (50rem on laptops, 58rem ≥100rem), sub-capped prose at 46rem, and capped + centered the `.main-frame` shell at 100rem. The widths left text, tables, and code ending at three different right edges, and the ultrawide cap broke outright — auto margins on a flex item size it to fit-content (the column measured 544px instead of 720px at 1680px) while the `position: fixed` left sidebar stayed glued to the viewport edge as the frame centered away from it. One stock 45rem track at every width keeps every block on the same edges and the right-rail TOC at its designed share of the leftover. Within that column nothing scrolls sideways: code blocks wrap (`expressiveCode.defaultProps.wrap`, hanging indent preserved), table code wraps BETWEEN tokens (at spaces, and after slashes in paths, via per-token nowrap spans + `` — never mid-token, so `--url` can't shred into two flags), Mermaid diagrams scale-to-fit on desktop with click-to-zoom for detail (the natural-size pin that forced them into horizontal scrollers everywhere is now phone-only, where panning beats 3px labels), and a table becomes its own scroller only when measured as genuinely unfittable (the per-column min-width floors apply only then — applied unconditionally they were CREATING the scroll on tables that fit). The configuration reference also stacks each setting's YAML key over its env var in one column (the two were the widest columns in every table on the page). Verified zero page-level horizontal overflow across 14 pages × 12 widths (320–2560) × both themes; at desktop widths every table and code block fits the column except two tables in the ~90px window at the exact 72rem breakpoint, where stock Starlight squeezes the pane below 45rem. A third round refined the breakout on review feedback: tables now EARN their width (a table whose prose wraps comfortably at 45rem stays at prose width — stretching its sentences to 60rem line lengths reads worse, not better; only tables that overflow or crowd at prose width take the wide track, capped 2.5rem short of the rails), diagram scrollers get an explicit `min(natural-width, pane)` width (fit-content can't measure a `width:100%` SVG child — wide diagrams were pinned at prose width while small ones already sat at natural size), the page footer (edit link / last-updated / prev-next) stays at prose width inside the un-capped container, and the edge-fade threshold rose to 8px so devicePixelRatio rounding can't paint a "more to scroll" fade on blocks with nothing to scroll — with a matching LEFT fade when scrolled (the right-only mask read as content amputated behind you). A follow-up round then let wide content earn width back without disturbing that geometry: the markdown body becomes a 3-track grid (the classic full-bleed pattern — prose capped in the centered middle track at the stock 45rem; the page title and prose share a left edge via `--sl-content-margin-inline: auto`), and table/code/diagram blocks span all three tracks sized `fit-content` — a short block stays at prose width with aligned edges, a wide one grows centered up to the main pane, whose width is a function of the viewport alone, so the right-rail TOC never moves and the page still can't scroll sideways. Table headers gained real emphasis (0.8125rem/650 on full ink over the elevated band, stronger underline), and the wrap-vs-scroll call became a crowding heuristic: a ≥4-column table averaging under ~8.5rem per column reads worse wrapped than panned, so it scrolls instead of wrapping every other word.
+  - **Layout** — settled on Starlight's stock geometry after a measured detour: an earlier round widened the content track in steps (50rem on laptops, 58rem ≥100rem), sub-capped prose at 46rem, and capped + centered the `.main-frame` shell at 100rem. The widths left text, tables, and code ending at three different right edges, and the ultrawide cap broke outright — auto margins on a flex item size it to fit-content (the column measured 544px instead of 720px at 1680px) while the `position: fixed` left sidebar stayed glued to the viewport edge as the frame centered away from it. One stock 45rem track at every width keeps every block on the same edges and the right-rail TOC at its designed share of the leftover. Within that column nothing scrolls sideways: code blocks wrap (`expressiveCode.defaultProps.wrap`, hanging indent preserved), table code wraps BETWEEN tokens (at spaces, and after slashes in paths, via per-token nowrap spans + `` — never mid-token, so `--url` can't shred into two flags), Mermaid diagrams scale-to-fit on desktop with click-to-zoom for detail (the natural-size pin that forced them into horizontal scrollers everywhere is now phone-only, where panning beats 3px labels), and a table becomes its own scroller only when measured as genuinely unfittable (the per-column min-width floors apply only then — applied unconditionally they were CREATING the scroll on tables that fit). The configuration reference also stacks each setting's YAML key over its env var in one column (the two were the widest columns in every table on the page). Verified zero page-level horizontal overflow across 14 pages × 12 widths (320–2560) × both themes; at desktop widths every table and code block fits the column except two tables in the ~90px window at the exact 72rem breakpoint, where stock Starlight squeezes the pane below 45rem. A third (and fourth) round refined the breakout on review feedback: tables now EARN their width (a table whose prose wraps comfortably at 45rem stays at prose width — stretching its sentences to 60rem line lengths reads worse, not better; only tables that overflow or crowd at prose width take the wide track, capped 2.5rem short of the rails), diagram scrollers get an explicit `min(natural-width, pane)` width (fit-content can't measure a `width:100%` SVG child — wide diagrams were pinned at prose width while small ones already sat at natural size), the page footer (edit link / last-updated / prev-next) stays at prose width inside the un-capped container, and the edge-fade threshold rose to 8px so devicePixelRatio rounding can't paint a "more to scroll" fade on blocks with nothing to scroll — with a matching LEFT fade when scrolled (the right-only mask read as content amputated behind you). The width-earning then got a natural-width sense: each table's max-content width is measured in a batched reflow, and a table whose natural width is at or near the breakout track (≤1.3×) widens to stop wrapping entirely (the why-wavehouse matrices), while prose-heavy tables (natural width = whole unwrapped sentences) keep the reading measure; ≥4-column tables under ~12.5rem average also widen. Mermaid cluster strokes stopped being sliced by the scroller's overflow-y (2px block padding), svgs sitting directly inside a `.diagram-pair` get their own scroller each (the pair container itself was being classed as one scroller — two diagrams shared one zoom chip and one natural width, and one diagram's highlight box clipped), and the fade threshold rose again to 12px after a phantom fade reproduced at devicePixelRatio 2. A follow-up round then let wide content earn width back without disturbing that geometry: the markdown body becomes a 3-track grid (the classic full-bleed pattern — prose capped in the centered middle track at the stock 45rem; the page title and prose share a left edge via `--sl-content-margin-inline: auto`), and table/code/diagram blocks span all three tracks sized `fit-content` — a short block stays at prose width with aligned edges, a wide one grows centered up to the main pane, whose width is a function of the viewport alone, so the right-rail TOC never moves and the page still can't scroll sideways. Table headers gained real emphasis (0.8125rem/650 on full ink over the elevated band, stronger underline), and the wrap-vs-scroll call became a crowding heuristic: a ≥4-column table averaging under ~8.5rem per column reads worse wrapped than panned, so it scrolls instead of wrapping every other word.
   - **Landing atmosphere (re-architected)** — the grid + glow lived *inside* `.wh-hero`, whose `overflow: hidden` clipped them to a centered ~1080px box (flat margins on wide screens, a hard rectangular edge, the glow sliced into an abrupt colored corner, and nothing under the navbar or below the hero). They're now two **page-level** fixed layers scoped via `body:has(.wh-hero)` — a continuous full-viewport "signal-field" grid (teal-tinted, soft-cornered vignette, runs edge-to-edge under the translucent navbar and all the way down the page) and a soft, breathing teal hero glow with pure radial falloff (no clipped edges) — so the background is genuinely full-bleed, top-anchored, and continuous in both themes. The hero's own clip + orb elements are gone; dead `.wh-grid-bg`/`.wh-glow-orb`/`.wh-glass` utilities removed. (Both page-level layers sit at negative z-index, which Starlight's *layered* opaque `body` background painted over — so the landing `body` is set transparent, letting `html`'s base color be the canvas and the grid/glow paint above it.)
   - **Hero CTAs** — the `pnpm add @wavehouse/sdk` hint became a **click-to-copy** command chip (faux terminal prompt, copy→check feedback, clipboard-API with a selection-range fallback), and a **live `★ N stars` chip** joins the hero meta — fed by the same demo `pipe()` data as the panel, so the hero carries live social proof and reinforces the real-time story.
   - **Live "wow" metrics in the demo panel** — the panel now proves the real-time claim other tools can't make: an **`⚡ ingested → live in ~N ms`** readout computed in the browser from each SSE event's `received_timestamp` (WaveHouse's server ingest clock; ingest → broadcast → browser, clamped for clock skew — Wave-RF/WaveHouse-Stats#33), a **live events/min sparkline** (new `gh_events_per_minute` pipe, bumped per streamed event — Stats#34), and a lifetime **`N tracked`** total (`gh_summary.total_events`). Each degrades to a placeholder when the stream is quiet or a pipe is unavailable.
@@ -146,7 +146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ### Changed
 
-- **Config and policy examples in every supported format, plus reference-page chrome odds and ends** (`docs/src/content/docs/{configuration.mdx,access-control.mdx}` (renamed from `.md`), `docs/src/content/docs/{architecture,development,pipes,claude-code}.md`, `docs/src/styles/global.css`): the example config file is now YAML / Environment synced tabs (`syncKey="cfg"`; every `WH_*` name cross-checked against `internal/config/config.go` — the config FILE is YAML-only by struct tags, so no JSON tab there), and the complete policy example is YAML / JSON synced tabs (`syncKey="policy"`; the JSON twin machine-verified equal to the YAML). Code frames that represent real files gained `title=` headers (`config.yaml`, `policy.yaml`, `.env`, `~/.claude.json`, …) and every frame now shows a quiet bottom-right language badge (`data-language`, suppressed on terminal/plain-text frames). Bare issue references in prose became links (#97, #223, #224, #279). The mobile icon-only search trigger joins the menu button's circular control family (it rendered as an unstyled square), and external sidebar links (Contributing Guide, Discussions, …) carry a `↗` marker.
+- **Config and policy examples in every supported format, plus reference-page chrome odds and ends** (`docs/src/content/docs/{configuration.mdx,access-control.mdx}` (renamed from `.md`), `docs/src/content/docs/{architecture,development,pipes,claude-code}.md`, `docs/src/styles/global.css`): the example config file is now YAML / Environment synced tabs (`syncKey="cfg"`; every `WH_*` name cross-checked against `internal/config/config.go` — the config FILE is YAML-only by struct tags, so no JSON tab there), and the complete policy example is YAML / JSON synced tabs (`syncKey="policy"`; the JSON twin machine-verified equal to the YAML). Code frames that represent real files gained `title=` headers (`config.yaml`, `policy.yaml`, `.env`, `~/.claude.json`, …) and every frame — terminals included — now shows a quiet language badge at its top-right (inside the title bar where one exists; ScrollHints copies `pre[data-language]` up to the frame since CSS `attr()` can't cross elements; plain-text-family frames stay unlabeled, and the badge yields to the hover copy button on headerless frames). A follow-up round, on review feedback: every substantive policy fragment on the access-control page is now a YAML/JSON tab group (7 groups total, each JSON twin machine-parsed equal to its YAML), the configuration tables returned to separate YAML Key / Env Var columns (the stacked merge predated wide-table support; 4-column tables now ride the breakout track), code-format tab groups DOCK onto their code frame (flush bottom edge, shared corner — a floating segmented control above an unrelated box read as disconnected), external-link `↗` markers extend to prose and footer links (absolute http(s) hrefs are off-site by construction), and the diagram zoom chip became a proper masked stroke icon on a translucent blurred chip instead of a `⤢` text glyph. Bare issue references in prose became links (#97, #223, #224, #279). The mobile icon-only search trigger joins the menu button's circular control family (it rendered as an unstyled square), and external sidebar links (Contributing Guide, Discussions, …) carry a `↗` marker.
 - **SDK docs split into six topic pages, with package-manager-synced install tabs** (`docs/src/content/docs/sdk/{index.mdx,queries.md,streaming.md,pipes.md,admin.md,reference.md}` (new), `docs/src/content/docs/sdk.md` (deleted), `docs/src/config/sidebar.ts`, `docs/src/components/Hero.astro`): the 806-line monolith becomes `/sdk` (overview: install, quick start, client setup, `Result`, section cards) plus `/sdk/{queries,streaming,pipes,admin,reference}`, under a nested "TypeScript SDK" sidebar group (which also keeps the llms.txt page ordering coherent — the LLM outputs read the sidebar). Topic-first URLs are the multi-language plan on record: when a second SDK language lands, the shared usage pages grow `` code tabs and each language gets its own setup/caveats page, so the topic URLs never churn. Installation became `` (pnpm / npm / yarn / bun / deno / CDN), and the landing hero's click-to-copy install chip now follows that synced choice — Starlight persists the selected tab label in `localStorage` under its syncKey, the hero script maps it to the matching one-liner (CDN and anything unknown keep the pnpm default). Every inbound link from other pages already pointed at bare `/sdk` (still the overview); the three intra-page `#streaming` anchors became `/sdk/streaming` links, and `starlight-links-validator` gates the rest. A follow-up adds an "Imports & Runtimes" section to the overview — import-line tabs synced to the same `pkg` choice (the CDN reader sees the esm.sh import everywhere, including a fully-tabbed Quick Start), Node CJS `require()` and the `WaveHouse` IIFE global documented from the shipped exports map, and a verified runtime-support note (streaming needs an `EventSource` polyfill in Node — the global is still flag-gated as of Node 26, checked empirically; non-streaming works on Node 18+). Each topic page points CDN readers at the section.
 - **Pre-push CI marker is tree-keyed, not commit-keyed** (`Makefile`, `.githooks/pre-push`, `.claude/hooks/agent-bash-gate.sh`, `AGENTS.md`). The `make ci → git add → git commit → git push` flow no longer false-positive-blocks — committing advances HEAD but doesn't change the tree CI validated. `make ci` writes `tmp/ci-passed-tree-` computed against a throwaway `GIT_INDEX_FILE` (so untracked files are captured without touching the real index); pre-push and the agent-bash-gate resolve each pushed ref's `^{tree}` and match. Skipped on CI runners (they don't push). `review-passed` stays commit-keyed — a new commit needs a new review.
 - **Worktrunk seeds new worktrees from main** (`.config/wt.toml`). Added `wt step copy-ignored` to `post-start` ahead of `make tools`, so `wt switch --create ` copies `.bin/` and the three `node_modules/` trees (~1 GB combined) from main instead of cold-starting `pnpm install --frozen-lockfile` × 3. `[step.copy-ignored] exclude` keeps `tmp/`, `data/`, and vitest's `coverage/` out.
diff --git a/docs/src/components/ScrollHints.astro b/docs/src/components/ScrollHints.astro
index e4aa89d2..3e21c9a4 100644
--- a/docs/src/components/ScrollHints.astro
+++ b/docs/src/components/ScrollHints.astro
@@ -23,8 +23,12 @@
       ".expressive-code pre, .sl-markdown-content table, .wh-scroll-x, .wh-zoom__stage";
 
     function wrapScrollables() {
+      // Bare svgs in the content flow AND direct svg children of a
+      // .diagram-pair (rehype-mermaid sometimes unwraps the 

) each get + // their OWN scroller — classing the pair container itself gave two + // diagrams one shared zoom chip and one shared natural width. const bare = document.querySelectorAll( - ".sl-markdown-content > svg[aria-roledescription]", + ".sl-markdown-content > svg[aria-roledescription], .diagram-pair > svg[aria-roledescription]", ); for (const svg of bare) { const div = document.createElement("div"); @@ -56,10 +60,24 @@ ); for (const svg of wrapped) { const parent = svg.parentElement; - if (parent && !parent.classList.contains("wh-scroll-x")) { + if ( + parent && + !parent.classList.contains("wh-scroll-x") && + !parent.classList.contains("diagram-pair") + ) { parent.classList.add("wh-scroll-x"); } } + // Surface each code frame's language for the corner badge — CSS attr() + // can only read from the element it styles, and EC stamps the language + // on the

 while the badge needs to live on the frame (inside the
+      // title bar for terminal/titled frames).
+      for (const pre of document.querySelectorAll(".expressive-code pre[data-language]")) {
+        const frame = pre.closest(".frame");
+        if (frame && !frame.hasAttribute("data-wh-lang")) {
+          frame.setAttribute("data-wh-lang", pre.dataset.language);
+        }
+      }
       // Tables: a scroll WRAPPER lets the table itself use true auto layout
       // (prose wraps first; the wrapper scrolls only when unbreakable columns
       // genuinely can't fit). Without JS, the table's own display:block
@@ -120,12 +138,13 @@
       }
     }
 
-    // 8px threshold, not ~0: fit-content widths and devicePixelRatio rounding
-    // leave a few phantom pixels of "overflow" on blocks with nothing real to
-    // scroll to — fading those reads as cut-off content that isn't there.
+    // 12px threshold, not ~0: fit-content widths and devicePixelRatio
+    // rounding leave phantom pixels of "overflow" on blocks with nothing
+    // real to scroll to — fading those reads as cut-off content that isn't
+    // there. Anything under ~a character width isn't worth hinting anyway.
     function updateOne(el) {
-      const end = el.scrollWidth - el.clientWidth - el.scrollLeft > 8;
-      const start = el.scrollLeft > 8;
+      const end = el.scrollWidth - el.clientWidth - el.scrollLeft > 12;
+      const start = el.scrollLeft > 12;
       el.toggleAttribute("data-wh-more", end);
       el.toggleAttribute("data-wh-more-start", start);
     }
@@ -147,21 +166,62 @@
     // auto layout crushing off-screen columns, but applied to a fitting
     // table they would CREATE the scroll).
     function classifyTables() {
-      const CROWD_PX = 136;
-      const overOrCrowded = (el) => {
+      const CROWD_PX = 136; // below this avg column width, scroll beats wrapping
+      const TIGHT_PX = 200; // below this, a ≥4-col table is worth widening
+      const colsOf = (el) => {
         const row = el.querySelector("tr");
-        const cols = row ? row.cells.length : 0;
-        const crowded = cols >= 4 && el.clientWidth / cols < CROWD_PX;
-        return crowded || el.scrollWidth - el.clientWidth > 1;
+        return row ? row.cells.length : 0;
       };
-      const wraps = document.querySelectorAll(".wh-table-wrap");
+      const overflowing = (el, slack) => el.scrollWidth - el.clientWidth > slack;
+      const wraps = [...document.querySelectorAll(".wh-table-wrap")];
       wraps.forEach((el) => {
         el.removeAttribute("data-wh-scrolls");
         el.removeAttribute("data-wh-wide");
       });
-      const wide = [...wraps].filter(overOrCrowded);
+      // Natural (max-content) width per table, measured in one batched
+      // reflow: a table whose natural width fits the breakout track widens
+      // to EXACTLY that width and stops wrapping entirely (feature matrices)
+      // — while prose-heavy tables (whose natural width is a full unwrapped
+      // sentence) don't widen just to stretch their line lengths.
+      const naturals = new Map();
+      wraps.forEach((el) => {
+        const t = el.querySelector("table");
+        if (t) t.style.width = "max-content";
+      });
+      wraps.forEach((el) => {
+        const t = el.querySelector("table");
+        if (t) naturals.set(el, t.getBoundingClientRect().width);
+      });
+      wraps.forEach((el) => {
+        const t = el.querySelector("table");
+        if (t) t.style.width = "";
+      });
+      const md = document.querySelector(".sl-markdown-content");
+      // Mirror the CSS cap: min(calc(100% - 2.5rem), 68rem).
+      const avail = md ? Math.min(md.clientWidth - 40, 1088) : 0;
+      const wide = wraps.filter((el) => {
+        const cols = colsOf(el);
+        const natural = naturals.get(el) ?? 0;
+        // The 1.3 factor: a table whose natural width is CLOSE to the
+        // breakout track still wins from widening (a touch of wrapping at
+        // full width beats heavy wrapping at prose width) — while genuinely
+        // prose-heavy tables (natural = whole unwrapped sentences, several
+        // times the track) stay at the reading measure.
+        return (
+          overflowing(el, 1) ||
+          (cols >= 4 && el.clientWidth / cols < TIGHT_PX) ||
+          (natural > el.clientWidth + 8 && natural <= avail * 1.3)
+        );
+      });
       wide.forEach((el) => el.setAttribute("data-wh-wide", ""));
-      wide.filter(overOrCrowded).forEach((el) => el.setAttribute("data-wh-scrolls", ""));
+      // Re-measure at the granted width: still overflowing or crowded →
+      // scroller + column floors.
+      wide
+        .filter((el) => {
+          const cols = colsOf(el);
+          return overflowing(el, 8) || (cols >= 4 && el.clientWidth / cols < CROWD_PX);
+        })
+        .forEach((el) => el.setAttribute("data-wh-scrolls", ""));
     }
     function init() {
       wrapScrollables();
@@ -204,6 +264,9 @@
     scrollbar-color: var(--wh-border-strong) transparent;
     margin-block: 1rem;
     position: relative;
+    /* Mermaid draws cluster borders ON the viewBox edge; overflow-y:hidden
+       was slicing the stroke's outer half at the top/bottom. */
+    padding-block: 2px;
   }
   /* Edge fades: content hidden past an edge reads as scrollable instead of
      amputated — on BOTH sides (a right-scrolled view fades its left edge so
@@ -274,29 +337,47 @@
   }
   /* Every diagram carries a quiet zoom affordance — cursor: zoom-in only
      speaks to a pointer that happens to hover, so the corner chip is always
-     present (dimmed) and brightens on hover. */
+     present (dimmed) and brightens on hover. Two pseudo layers: ::after is
+     the chip surface, ::before the icon (a stroke "expand" glyph applied as
+     a mask so it takes theme colors — a data-URI background can't use
+     var()). The semi-translucent surface keeps whatever diagram edge it
+     overlaps readable underneath. */
   .wh-scroll-x:not(.wh-table-wrap)::after {
-    content: "⤢";
+    content: "";
     position: absolute;
-    top: 0.4rem;
-    right: 0.4rem;
-    display: grid;
-    place-items: center;
-    width: 1.6rem;
-    height: 1.6rem;
+    top: 0.45rem;
+    right: 0.45rem;
+    width: 1.75rem;
+    height: 1.75rem;
     border-radius: var(--radius-xs);
-    background: var(--wh-surface-overlay);
+    background: color-mix(in oklab, var(--wh-surface-overlay) 80%, transparent);
+    backdrop-filter: blur(3px);
     border: 1px solid var(--wh-border-strong);
-    color: var(--wh-ink-muted);
-    font-size: 0.95rem;
-    line-height: 1;
     pointer-events: none;
-    opacity: 0.6;
-    transition: opacity 0.15s ease, border-color 0.15s ease, color 0.15s ease;
+    opacity: 0.65;
+    transition: opacity 0.15s ease, border-color 0.15s ease;
+  }
+  .wh-scroll-x:not(.wh-table-wrap)::before {
+    content: "";
+    position: absolute;
+    top: calc(0.45rem + 7px);
+    right: calc(0.45rem + 7px);
+    width: 14px;
+    height: 14px;
+    z-index: 1;
+    background-color: var(--wh-ink-muted);
+    mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='15 3 21 3 21 9'/%3E%3Cpolyline points='9 21 3 21 3 15'/%3E%3Cline x1='21' y1='3' x2='14' y2='10'/%3E%3Cline x1='3' y1='21' x2='10' y2='14'/%3E%3C/svg%3E");
+    mask-size: 100% 100%;
+    pointer-events: none;
+    opacity: 0.85;
+    transition: background-color 0.15s ease;
   }
   .wh-scroll-x:not(.wh-table-wrap):hover::after {
     opacity: 1;
     border-color: var(--wh-accent);
-    color: var(--wh-accent-text);
+  }
+  .wh-scroll-x:not(.wh-table-wrap):hover::before {
+    background-color: var(--wh-accent-text);
+    opacity: 1;
   }
 
diff --git a/docs/src/content/docs/access-control.mdx b/docs/src/content/docs/access-control.mdx
index a13eb486..f4171646 100644
--- a/docs/src/content/docs/access-control.mdx
+++ b/docs/src/content/docs/access-control.mdx
@@ -82,6 +82,8 @@ A policy is a YAML or JSON document with three top-level fields:
 
 Each table entry holds permissions for two operations — `select` (reads) and `insert` (writes) — and each operation maps **role → permissions**:
 
+
+  
 ```yaml
 tables:
   events:                 # ClickHouse table name
@@ -92,6 +94,28 @@ tables:
       writer:             # role → what "writer" may insert
         allow_columns: ["page", "score", "user_id"]
 ```
+  
+  
+```json
+{
+  "tables": {
+    "events": {
+      "select": {
+        "viewer": {
+          "allow_columns": ["page", "score"]
+        }
+      },
+      "insert": {
+        "writer": {
+          "allow_columns": ["page", "score", "user_id"]
+        }
+      }
+    }
+  }
+}
+```
+  
+
 
 A role with no entry under the operation it is attempting is denied. The set of permission fields below applies to both `select` and `insert` entries, though some (row `filter`, aggregations, limits) only take effect on the read path and some (`check`) only on the write path — the [enforcement table](#where-each-rule-is-enforced) maps which is which.
 
@@ -99,12 +123,28 @@ A role with no entry under the operation it is attempting is denied. The set of
 
 `allow_columns` and `deny_columns` control which columns a role can read (on `select`) or write (on `insert`).
 
+
+  
 ```yaml
 select:
   viewer:
     allow_columns: ["page", "button", "received_timestamp"]
     deny_columns: ["user_email", "ip_address"]
 ```
+  
+  
+```json
+{
+  "select": {
+    "viewer": {
+      "allow_columns": ["page", "button", "received_timestamp"],
+      "deny_columns": ["user_email", "ip_address"]
+    }
+  }
+}
+```
+  
+
 
 The rules, in order:
 
@@ -118,6 +158,8 @@ On a structured query (`POST /v1/query?table={table}`) the allowlist is a **hard
 
 `filter` restricts *which rows* a role can read by injecting a `WHERE` clause into the generated SQL. Each entry maps a column to a comparison whose value is usually a **JWT claim template**:
 
+
+  
 ```yaml
 select:
   viewer:
@@ -125,6 +167,23 @@ select:
       tenant_id:
         _eq: "{{ jwt.app_metadata.tenant_id }}"
 ```
+  
+  
+```json
+{
+  "select": {
+    "viewer": {
+      "filter": {
+        "tenant_id": {
+          "_eq": "{{ jwt.app_metadata.tenant_id }}"
+        }
+      }
+    }
+  }
+}
+```
+  
+
 
 This produces `WHERE (tenant_id = ?)` with the caller's `app_metadata.tenant_id` claim bound as the parameter — so a `viewer` only ever sees rows for their own tenant, and the value comes from the signed token, not from anything the client sends.
 
@@ -154,6 +213,8 @@ Values are always bound as SQL **parameters**, never concatenated into the query
 
 `check` is the write-path counterpart to `filter`: it constrains the *values* a role may insert, again via claim templates. It only supports `_eq`.
 
+
+  
 ```yaml
 insert:
   writer:
@@ -164,6 +225,27 @@ insert:
       tenant_id:
         _eq: "{{ jwt.app_metadata.tenant_id }}"
 ```
+  
+  
+```json
+{
+  "insert": {
+    "writer": {
+      "allow_columns": ["page", "score", "user_id", "tenant_id"],
+      "check": {
+        "user_id": {
+          "_eq": "{{ jwt.sub }}"
+        },
+        "tenant_id": {
+          "_eq": "{{ jwt.app_metadata.tenant_id }}"
+        }
+      }
+    }
+  }
+}
+```
+  
+
 
 For each checked column, on `POST /v1/ingest?table={table}`:
 
@@ -176,6 +258,8 @@ This pairs naturally with a matching `filter` on the `select` side: `check` stam
 
 `allowed_aggregations` and `denied_aggregations` restrict which aggregation functions a role may use in a structured query (`count`, `sum`, `avg`, `quantile`, …). Matching is case-insensitive and follows the same precedence as columns:
 
+
+  
 ```yaml
 select:
   viewer:
@@ -183,6 +267,22 @@ select:
   analyst:
     allowed_aggregations: ["count", "sum", "avg"]  # allowlist: only these three
 ```
+  
+  
+```json
+{
+  "select": {
+    "viewer": {
+      "denied_aggregations": ["quantile", "median"]
+    },
+    "analyst": {
+      "allowed_aggregations": ["count", "sum", "avg"]
+    }
+  }
+}
+```
+  
+
 
 - `denied_aggregations` always wins.
 - An empty `allowed_aggregations` means all non-denied functions are allowed; a non-empty list is an allowlist.
@@ -198,12 +298,28 @@ Two fields cap the cost of a single structured query. Both must be non-negative;
 | `max_rows` | Caps the query's `LIMIT`. If the caller asks for more (or omits a limit), the result is clamped to `max_rows`. |
 | `max_execution_time_ms` | Caps the ClickHouse execution timeout for the query, applied as the *minimum* of this value and the server's `clickhouse.query_timeout`. |
 
+
+  
 ```yaml
 select:
   viewer:
     max_rows: 1000
     max_execution_time_ms: 5000
 ```
+  
+  
+```json
+{
+  "select": {
+    "viewer": {
+      "max_rows": 1000,
+      "max_execution_time_ms": 5000
+    }
+  }
+}
+```
+  
+
 
 These bound a role's blast radius on the cached read path. They do not apply to raw admin SQL, which is unbounded by design (other than the 64 MiB response cap noted in the [API reference](/api)).
 
diff --git a/docs/src/content/docs/configuration.mdx b/docs/src/content/docs/configuration.mdx
index 1abc13b4..6d42886b 100644
--- a/docs/src/content/docs/configuration.mdx
+++ b/docs/src/content/docs/configuration.mdx
@@ -25,64 +25,64 @@ export WH_CONFIG=/etc/wavehouse/config.yaml
 
 ### State
 
-| Key (YAML · env) | Default | Description |
-| --- | ------- | ----------- |
-| `data_dir`
`WH_DATA_DIR` | `./data` | Root directory for embedded state. NATS JetStream lives at `/nats`; Pebble (when dedupe is enabled) at `/pebble`. Subdirectory names are conventions, not config — one knob, one mount. **In a container this MUST resolve to a host-backed volume**; the relative default is for local binary use. WaveHouse logs a startup `WARN` when the directory is missing or empty (no prior state). See [Persistent Storage](/deployment#persistent-storage-required-for-containers). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `data_dir` | `WH_DATA_DIR` | `./data` | Root directory for embedded state. NATS JetStream lives at `/nats`; Pebble (when dedupe is enabled) at `/pebble`. Subdirectory names are conventions, not config — one knob, one mount. **In a container this MUST resolve to a host-backed volume**; the relative default is for local binary use. WaveHouse logs a startup `WARN` when the directory is missing or empty (no prior state). See [Persistent Storage](/deployment#persistent-storage-required-for-containers). | ### Server -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `server.port`
`WH_SERVER_PORT` | `8080` | HTTP server listen port. | -| `server.shutdown_timeout`
`WH_SERVER_SHUTDOWN_TIMEOUT` | `10` | Graceful shutdown timeout in seconds. | -| `server.cors_allowed_origins`
`WH_SERVER_CORS_ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed CORS origins. `*` allows any browser origin. WaveHouse is a Bearer-token API — `Access-Control-Allow-Credentials` is intentionally never sent, so this allowlist controls *which origins can read responses*, not cookie scope. Tighten to your frontend's exact origin(s) in production (e.g. `https://dashboard.example.com,http://localhost:3000`). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `server.port` | `WH_SERVER_PORT` | `8080` | HTTP server listen port. | +| `server.shutdown_timeout` | `WH_SERVER_SHUTDOWN_TIMEOUT` | `10` | Graceful shutdown timeout in seconds. | +| `server.cors_allowed_origins` | `WH_SERVER_CORS_ALLOWED_ORIGINS` | `*` | Comma-separated list of allowed CORS origins. `*` allows any browser origin. WaveHouse is a Bearer-token API — `Access-Control-Allow-Credentials` is intentionally never sent, so this allowlist controls *which origins can read responses*, not cookie scope. Tighten to your frontend's exact origin(s) in production (e.g. `https://dashboard.example.com,http://localhost:3000`). | ### ClickHouse -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `clickhouse.addr`
`WH_CH_ADDR` | `localhost:9000` | ClickHouse native protocol address. | -| `clickhouse.http_port`
`WH_CH_HTTP_PORT` | `8123` | ClickHouse HTTP interface port. Used by the ingest worker (`internal/ingest`) for bulk INSERT and by the raw-SQL proxy (`POST /v1/admin/query`, `internal/api/query.go`) to forward SQL to ClickHouse. Schema discovery uses the native protocol on `addr` instead. | -| `clickhouse.http_scheme`
`WH_CH_HTTP_SCHEME` | `http` | HTTP scheme for the ClickHouse HTTP interface (`http` or `https`). Set to `https` for TLS-encrypted ClickHouse connections. | -| `clickhouse.database`
`WH_CH_DATABASE` | `default` | Database name. Tables are discovered from this database. | -| `clickhouse.username`
`WH_CH_USERNAME` | `default` | Authentication username. | -| `clickhouse.password`
`WH_CH_PASSWORD` | *(empty)* | Authentication password. | -| `clickhouse.query_timeout`
`WH_CH_QUERY_TIMEOUT` | `30s` | Maximum allowed execution time for ClickHouse queries | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `clickhouse.addr` | `WH_CH_ADDR` | `localhost:9000` | ClickHouse native protocol address. | +| `clickhouse.http_port` | `WH_CH_HTTP_PORT` | `8123` | ClickHouse HTTP interface port. Used by the ingest worker (`internal/ingest`) for bulk INSERT and by the raw-SQL proxy (`POST /v1/admin/query`, `internal/api/query.go`) to forward SQL to ClickHouse. Schema discovery uses the native protocol on `addr` instead. | +| `clickhouse.http_scheme` | `WH_CH_HTTP_SCHEME` | `http` | HTTP scheme for the ClickHouse HTTP interface (`http` or `https`). Set to `https` for TLS-encrypted ClickHouse connections. | +| `clickhouse.database` | `WH_CH_DATABASE` | `default` | Database name. Tables are discovered from this database. | +| `clickhouse.username` | `WH_CH_USERNAME` | `default` | Authentication username. | +| `clickhouse.password` | `WH_CH_PASSWORD` | *(empty)* | Authentication password. | +| `clickhouse.query_timeout` | `WH_CH_QUERY_TIMEOUT` | `30s` | Maximum allowed execution time for ClickHouse queries | ### Schema Discovery -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `schema.refresh_interval`
`WH_SCHEMA_REFRESH_INTERVAL` | `60` | How often (in seconds) to re-discover ClickHouse table schemas. Also refreshable on-demand via `POST /v1/schema/refresh` (admin-only). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `schema.refresh_interval` | `WH_SCHEMA_REFRESH_INTERVAL` | `60` | How often (in seconds) to re-discover ClickHouse table schemas. Also refreshable on-demand via `POST /v1/schema/refresh` (admin-only). | ### Message Queue (NATS) -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `mq.gap_window_minutes`
`WH_MQ_GAP_WINDOW_MINUTES` | `15` | How many minutes of messages to retain in NATS for SSE gap-fill. The Active Sweeper will not purge messages newer than this window. | -| `mq.max_bytes_gb`
`WH_MQ_MAX_BYTES_GB` | `50` | Maximum NATS JetStream stream size in GB. When full, new publishes are rejected with `DiscardNew` policy, triggering 503 backpressure on the ingest endpoint. | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `mq.gap_window_minutes` | `WH_MQ_GAP_WINDOW_MINUTES` | `15` | How many minutes of messages to retain in NATS for SSE gap-fill. The Active Sweeper will not purge messages newer than this window. | +| `mq.max_bytes_gb` | `WH_MQ_MAX_BYTES_GB` | `50` | Maximum NATS JetStream stream size in GB. When full, new publishes are rejected with `DiscardNew` policy, triggering 503 backpressure on the ingest endpoint. | ### Deduplication -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `dedupe.enabled`
`WH_DEDUPE_ENABLED` | `false` | Enable event deduplication. When enabled, the ingest handler checks for duplicates using the configured ID field. | -| `dedupe.id_field`
`WH_DEDUPE_ID_FIELD` | `event_id` | JSON field name in the ingest body used as the dedup key. | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `dedupe.enabled` | `WH_DEDUPE_ENABLED` | `false` | Enable event deduplication. When enabled, the ingest handler checks for duplicates using the configured ID field. | +| `dedupe.id_field` | `WH_DEDUPE_ID_FIELD` | `event_id` | JSON field name in the ingest body used as the dedup key. | ### Cache -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `cache.l1_max_cost`
`WH_CACHE_L1_MAX_COST` | `67108864` | Maximum L1 cache size in bytes (~64 MB). | -| `cache.timestamp_bucket_seconds`
`WH_CACHE_TIMESTAMP_BUCKET_SECONDS` | `60` | Bucket size (seconds) for time-range truncation in structured queries. Improves cache hit rate by normalizing timestamps. | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `cache.l1_max_cost` | `WH_CACHE_L1_MAX_COST` | `67108864` | Maximum L1 cache size in bytes (~64 MB). | +| `cache.timestamp_bucket_seconds` | `WH_CACHE_TIMESTAMP_BUCKET_SECONDS` | `60` | Bucket size (seconds) for time-range truncation in structured queries. Improves cache hit rate by normalizing timestamps. | ### Authentication -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `auth.jwt_secret`
`WH_AUTH_JWT_SECRET` | *(empty)* | HMAC secret for JWT validation. Set this (or `jwks_url`) so presented tokens are verified; see [Access Control](/access-control). | -| `auth.jwks_url`
`WH_AUTH_JWKS_URL` | *(empty)* | JWKS endpoint URL for public key validation (e.g., `https://auth.example.com/.well-known/jwks.json`). When set, JWKS is the **sole** verifier and `jwt_secret` is ignored (not a per-token fallback); the endpoint must be reachable at startup or the server fails to boot. | -| `auth.role_claim`
`WH_AUTH_ROLE_CLAIM` | `role` | Dot-separated JWT claim path for role extraction (e.g., `app_metadata.role`). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `auth.jwt_secret` | `WH_AUTH_JWT_SECRET` | *(empty)* | HMAC secret for JWT validation. Set this (or `jwks_url`) so presented tokens are verified; see [Access Control](/access-control). | +| `auth.jwks_url` | `WH_AUTH_JWKS_URL` | *(empty)* | JWKS endpoint URL for public key validation (e.g., `https://auth.example.com/.well-known/jwks.json`). When set, JWKS is the **sole** verifier and `jwt_secret` is ignored (not a per-token fallback); the endpoint must be reachable at startup or the server fails to boot. | +| `auth.role_claim` | `WH_AUTH_ROLE_CLAIM` | `role` | Dot-separated JWT claim path for role extraction (e.g., `app_metadata.role`). | WaveHouse accepts only the signing algorithms matching the active verifier — `HS256`/`HS384`/`HS512` for the HMAC secret, or the asymmetric family (`RS*`/`ES*`/`PS*`/`EdDSA`) for JWKS — and validates the token's `alg` before any key is used, so your IdP must sign with one of these and `alg: none` is always rejected. @@ -95,21 +95,21 @@ See [API — Authentication](/api#authentication). ### Dead Letter Queue (DLQ) -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `dlq.enabled`
`WH_DLQ_ENABLED` | `true` | Enable the Dead Letter Queue. Failed batch inserts are published to the `WAVEHOUSE_DLQ` NATS stream instead of blocking retries. | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `dlq.enabled` | `WH_DLQ_ENABLED` | `true` | Enable the Dead Letter Queue. Failed batch inserts are published to the `WAVEHOUSE_DLQ` NATS stream instead of blocking retries. | ### Access Control Policy -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `policy.file_path`
`WH_POLICY_FILE_PATH` | *(empty)* | Optional path to a YAML/JSON policy file used to seed the policy store on first startup (when NATS KV is empty). **When set, the file MUST exist and parse — WaveHouse refuses to boot otherwise**, so a typo or missing mount surfaces immediately instead of silently denying every request (`Evaluate` fails closed on a `nil` policy, including the admin role). Empty default — no implicit `policy.yaml` lookup — so operators opt into the bootstrap file explicitly; without one, seed the policy via `PUT /v1/admin/policy`. Once KV is populated, the file is ignored on subsequent boots (KV is the source of truth; runtime updates flow through the API and KV Watch). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `policy.file_path` | `WH_POLICY_FILE_PATH` | *(empty)* | Optional path to a YAML/JSON policy file used to seed the policy store on first startup (when NATS KV is empty). **When set, the file MUST exist and parse — WaveHouse refuses to boot otherwise**, so a typo or missing mount surfaces immediately instead of silently denying every request (`Evaluate` fails closed on a `nil` policy, including the admin role). Empty default — no implicit `policy.yaml` lookup — so operators opt into the bootstrap file explicitly; without one, seed the policy via `PUT /v1/admin/policy`. Once KV is populated, the file is ignored on subsequent boots (KV is the source of truth; runtime updates flow through the API and KV Watch). | ### Named Pipes -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `pipes.dir`
`WH_PIPES_DIR` | *(empty)* | Optional bootstrap source for named query pipes. When set, `.sql` files in this directory are loaded into the NATS KV pipe store on startup. The directory is **read-only at runtime** — it's a seed, not authoritative storage. After bootstrap, the API and KV are the source of truth (runtime pipe edits go through the API, not the files). Empty default skips bootstrap entirely. Mount read-only in containers (e.g. `./my-pipes:/app/pipes:ro`). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `pipes.dir` | `WH_PIPES_DIR` | *(empty)* | Optional bootstrap source for named query pipes. When set, `.sql` files in this directory are loaded into the NATS KV pipe store on startup. The directory is **read-only at runtime** — it's a seed, not authoritative storage. After bootstrap, the API and KV are the source of truth (runtime pipe edits go through the API, not the files). Empty default skips bootstrap entirely. Mount read-only in containers (e.g. `./my-pipes:/app/pipes:ro`). | ### OTel @@ -119,25 +119,25 @@ The master switch is `otel.enabled`. When `true`, each signal (traces/metrics/lo **TLS / direct-to-cloud limitation.** The OTLP exporters currently use `WithInsecure()` — plaintext gRPC only. WaveHouse cannot ship directly to TLS-protected OTLP endpoints (Grafana Cloud's OTLP gateway, Honeycomb, Datadog OTLP, etc.). The standard workaround is a sidecar collector (the OTel collector or Grafana Alloy) on `127.0.0.1:4317` that receives our plaintext OTLP and re-exports to the cloud endpoint with TLS + auth headers configured locally. Tracked in [#97](https://github.com/Wave-RF/WaveHouse/issues/97). -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `otel.enabled`
`WH_OTEL_ENABLED` | `false` | Master switch. When `false`, no signals are initialized regardless of the sub-toggles below. | -| `otel.addr`
`WH_OTEL_ADDR` | `127.0.0.1:4317` | OTLP gRPC endpoint used by every enabled signal. Plain `host:port` — no scheme, plaintext gRPC only (see TLS note above). See `make help` for a local collector setup. | -| `otel.traces.enabled`
`WH_OTEL_TRACES_ENABLED` | `true` | Export traces via OTLP gRPC. | -| `otel.traces.sample_rate`
`WH_OTEL_TRACES_SAMPLE_RATE` | `1.0` | Head-based trace sampling rate in `[0.0, 1.0]`. `1.0` exports every trace; `0.0` exports none. Defaults to 100% (matches the OpenTelemetry SDK default); lower it for high-QPS production services where collector or backend cost is a concern. Best practice is "100% at the source, downsample at the collector" via tail-based sampling. Validated at config load. | -| `otel.metrics.enabled`
`WH_OTEL_METRICS_ENABLED` | `true` | Export metrics + Go runtime metrics via OTLP gRPC. Periodic reader interval is fixed at 15s. Metrics are pre-aggregated so there is no sampling knob. | -| `otel.logs.enabled`
`WH_OTEL_LOGS_ENABLED` | `true` | Export logs via OTLP gRPC. Disabling this leaves stdout logging untouched — the OTel logger provider is simply not registered. | -| `otel.logs.sample_rate`
`WH_OTEL_LOGS_SAMPLE_RATE` | `1.0` | OTLP export rate for `DEBUG`/`INFO` records, in `[0.0, 1.0]`. Validated at config load. `WARN` and `ERROR` records always export at 100% — dropping them silently during incidents is too dangerous to expose as a knob. **Stdout always receives 100% of records regardless of this rate** (see the scraper note above). | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `otel.enabled` | `WH_OTEL_ENABLED` | `false` | Master switch. When `false`, no signals are initialized regardless of the sub-toggles below. | +| `otel.addr` | `WH_OTEL_ADDR` | `127.0.0.1:4317` | OTLP gRPC endpoint used by every enabled signal. Plain `host:port` — no scheme, plaintext gRPC only (see TLS note above). See `make help` for a local collector setup. | +| `otel.traces.enabled` | `WH_OTEL_TRACES_ENABLED` | `true` | Export traces via OTLP gRPC. | +| `otel.traces.sample_rate` | `WH_OTEL_TRACES_SAMPLE_RATE` | `1.0` | Head-based trace sampling rate in `[0.0, 1.0]`. `1.0` exports every trace; `0.0` exports none. Defaults to 100% (matches the OpenTelemetry SDK default); lower it for high-QPS production services where collector or backend cost is a concern. Best practice is "100% at the source, downsample at the collector" via tail-based sampling. Validated at config load. | +| `otel.metrics.enabled` | `WH_OTEL_METRICS_ENABLED` | `true` | Export metrics + Go runtime metrics via OTLP gRPC. Periodic reader interval is fixed at 15s. Metrics are pre-aggregated so there is no sampling knob. | +| `otel.logs.enabled` | `WH_OTEL_LOGS_ENABLED` | `true` | Export logs via OTLP gRPC. Disabling this leaves stdout logging untouched — the OTel logger provider is simply not registered. | +| `otel.logs.sample_rate` | `WH_OTEL_LOGS_SAMPLE_RATE` | `1.0` | OTLP export rate for `DEBUG`/`INFO` records, in `[0.0, 1.0]`. Validated at config load. `WARN` and `ERROR` records always export at 100% — dropping them silently during incidents is too dangerous to expose as a knob. **Stdout always receives 100% of records regardless of this rate** (see the scraper note above). | ### Prometheus Prometheus exposition is its own top-level config block, independent of `otel.*`. Operators using a scrape-based pipeline (Grafana Alloy, Mimir, the standalone Prometheus server) can leave the entire `[otel]` block at its `enabled: false` default and turn on only `prometheus.enabled` — no OTLP collector required. Conversely, OTLP push and Prometheus can both be on at once (the underlying OTel MeterProvider drives both readers, same `Meter()` API). Disabled by default since enabling adds an unauthenticated endpoint, so opt-in is explicit. -| Key (YAML · env) | Default | Description | -| --- | ------- | ----------- | -| `prometheus.enabled`
`WH_PROMETHEUS_ENABLED` | `false` | Expose a Prometheus-format `/metrics` endpoint. Works standalone (no OTel push) or alongside `otel.metrics.enabled`. | -| `prometheus.path`
`WH_PROMETHEUS_PATH` | `/metrics` | URL path. Must start with `/`, and may not collide with a reserved probe path (`/livez`, `/readyz`, `/healthz`, `/health`, `/ready`). When `port` is `0` (mounted on the main server) it also may not be `/v1` or sit under `/v1/`, which would shadow the authenticated API. An invalid path fails validation at startup. | -| `prometheus.port`
`WH_PROMETHEUS_PORT` | `0` | Listener port. `0` mounts the endpoint on the main API server (`server.port`) — simplest, no extra port to expose. Non-zero spins up a dedicated HTTP listener, which lets you firewall metrics off the public API surface (common production posture). Must not equal `server.port` when non-zero. | +| YAML Key | Env Var | Default | Description | +| --- | --- | ------- | ----------- | +| `prometheus.enabled` | `WH_PROMETHEUS_ENABLED` | `false` | Expose a Prometheus-format `/metrics` endpoint. Works standalone (no OTel push) or alongside `otel.metrics.enabled`. | +| `prometheus.path` | `WH_PROMETHEUS_PATH` | `/metrics` | URL path. Must start with `/`, and may not collide with a reserved probe path (`/livez`, `/readyz`, `/healthz`, `/health`, `/ready`). When `port` is `0` (mounted on the main server) it also may not be `/v1` or sit under `/v1/`, which would shadow the authenticated API. An invalid path fails validation at startup. | +| `prometheus.port` | `WH_PROMETHEUS_PORT` | `0` | Listener port. `0` mounts the endpoint on the main API server (`server.port`) — simplest, no extra port to expose. Non-zero spins up a dedicated HTTP listener, which lets you firewall metrics off the public API surface (common production posture). Must not equal `server.port` when non-zero. | ### Logging diff --git a/docs/src/content/docs/development.md b/docs/src/content/docs/development.md index ae896702..6f381982 100644 --- a/docs/src/content/docs/development.md +++ b/docs/src/content/docs/development.md @@ -244,7 +244,6 @@ curl -s -X POST "http://localhost:8080/v1/ingest?table=clicks" \ ### Using an .env File ```bash title=".env" -# .env export WH_CH_ADDR=localhost:9000 ``` diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index e141977d..419d3a4b 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -346,9 +346,17 @@ svg[aria-roledescription^="state"] { margin-block: 1.5rem; align-items: center; } -.sl-markdown-content .diagram-pair > p { +/* Pair children are

s in authored markdown, or the runtime div scrollers + ScrollHints wraps direct-child svgs in — both must span the pair (a + content-sized flex item would overflow the page on phones, where the + diagram floor exceeds the viewport and only a full-width scroller can + absorb it). The

centers its inline svg via flex; the div scroller's + svg centers itself (margin-inline: auto) and pans when oversized. */ +.sl-markdown-content .diagram-pair > :is(p, div) { margin: 0; width: 100%; +} +.sl-markdown-content .diagram-pair > p { display: flex; justify-content: center; } @@ -636,6 +644,18 @@ svg[aria-roledescription^="flowchart"] .cluster rect { text-decoration-color: var(--wh-accent-text); } +/* External links announce themselves everywhere, not just the sidebar — + absolute http(s) hrefs are off-site by construction (internal links are + root-relative). Buttons and cards carry their own affordances. */ +.sl-markdown-content a[href^="http"]:not(:where(.sl-link-button, .sl-link-card, .not-content a))::after, +footer a[href^="http"]::after { + content: "↗"; + display: inline-block; + margin-inline-start: 0.25em; + font-size: 0.7em; + opacity: 0.6; +} + /* Inline code — denser, branded surface */ .sl-markdown-content :not(pre) > code { background: var(--wh-code-bg); @@ -681,19 +701,22 @@ samp, border-color: var(--wh-accent); } -/* Language badge — every code frame names its language in the bottom-right - corner (Expressive Code stamps data-language on the

). Quiet by
-   design: readers who need it find it, everyone else reads past it.
-   Terminal frames skip it (the title bar already says "shell"), as do
-   plain-text-family blocks where a label would be noise. */
-.expressive-code pre {
+/* Language badge — EVERY code frame names its language at its top-right
+   (ScrollHints copies Expressive Code's pre[data-language] up to the frame,
+   where the badge can sit inside the title bar). Quiet by design: readers
+   who need it find it, everyone else reads past it. Placement varies with
+   the frame anatomy — in the header band for terminal/titled frames, in
+   the code area's corner for plain frames (where it yields to the hover
+   copy button). Plain-text-family blocks skip it (no information). */
+.expressive-code .frame[data-wh-lang] {
   position: relative;
 }
-.expressive-code pre::after {
-  content: attr(data-language);
+.expressive-code .frame[data-wh-lang]::after {
+  content: attr(data-wh-lang);
   position: absolute;
-  bottom: 0.45rem;
-  right: 0.65rem;
+  z-index: 2;
+  top: 0.85rem;
+  right: 0.85rem;
   font-family: var(--sl-font-mono);
   font-size: 0.65rem;
   font-weight: 600;
@@ -701,16 +724,21 @@ samp,
   letter-spacing: 0.08em;
   text-transform: uppercase;
   color: var(--wh-ink-subtle);
-  opacity: 0.75;
+  opacity: 0.8;
   pointer-events: none;
   user-select: none;
+  transition: opacity 0.15s ease;
+}
+/* Headerless frames: the badge shares the corner with the hover-revealed
+   copy button — step aside when the pointer is on the frame. */
+.expressive-code .frame[data-wh-lang]:not(.has-title):not(.is-terminal):hover::after {
+  opacity: 0;
 }
-.expressive-code .frame.is-terminal pre::after,
-.expressive-code pre:is(
-    [data-language="text"],
-    [data-language="plaintext"],
-    [data-language="txt"],
-    [data-language="ansi"]
+.expressive-code .frame:is(
+    [data-wh-lang="text"],
+    [data-wh-lang="plaintext"],
+    [data-wh-lang="txt"],
+    [data-wh-lang="ansi"]
   )::after {
   content: none;
 }
@@ -1346,6 +1374,34 @@ starlight-tabs .tab [role="tab"]:not([aria-selected="true"]):hover {
   background: color-mix(in oklab, var(--wh-surface-elevated) 60%, transparent);
 }
 
+/* Code-format tab groups (YAML/JSON, YAML/Environment, the pkg install
+   switcher) — when a panel leads with a code frame, the segmented control
+   DOCKS onto it (flush bottom edge, shared corner) so the pair reads as one
+   unit instead of a floating control above an unrelated box. */
+.sl-markdown-content
+  starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child)
+  .tablist-wrapper
+  ul[role="tablist"] {
+  margin-bottom: 0;
+  border-bottom-left-radius: 0;
+  border-bottom-right-radius: 0;
+  border-bottom-color: transparent;
+  position: relative;
+  z-index: 1;
+}
+.sl-markdown-content
+  starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child)
+  > section[role="tabpanel"] {
+  margin-top: 0;
+}
+.sl-markdown-content
+  starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child)
+  > section[role="tabpanel"]
+  > .expressive-code:first-child
+  :is(.frame, figcaption.header, pre) {
+  border-top-left-radius: 0;
+}
+
 /* ---------- Small chrome the design system hadn't reached ---------- */
 
 /* Heading anchor links — quiet by default, accent on hover (stock gray). */

From 4c2d50d1a4f56a9c8b988a9c9b0e93b099cbc753 Mon Sep 17 00:00:00 2001
From: Eric Andrechek 
Date: Wed, 10 Jun 2026 11:43:54 -0400
Subject: [PATCH 19/23] =?UTF-8?q?fix(docs):=20dock=20tabs=20onto=20div[rol?=
 =?UTF-8?q?e=3Dtabpanel]=20=E2=80=94=20Starlight=20renders=20div,=20not=20?=
 =?UTF-8?q?section?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The docked-format-tabs rules keyed on section[role="tabpanel"], but
Starlight's rehype-tabs emits 
— every docking rule silently matched nothing (pre-push reviewer catch). Now [role="tabpanel"] tag-agnostic; verified gap=0 and shared 0-radius corner in headless Chromium. Also suppress the corner language badge inside docked groups — the active tab already names the block. Co-Authored-By: Claude Fable 5 --- docs/src/styles/global.css | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/src/styles/global.css b/docs/src/styles/global.css index 419d3a4b..97dec558 100644 --- a/docs/src/styles/global.css +++ b/docs/src/styles/global.css @@ -1379,7 +1379,7 @@ starlight-tabs .tab [role="tab"]:not([aria-selected="true"]):hover { DOCKS onto it (flush bottom edge, shared corner) so the pair reads as one unit instead of a floating control above an unrelated box. */ .sl-markdown-content - starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child) + starlight-tabs:has(> [role="tabpanel"] > .expressive-code:first-child) .tablist-wrapper ul[role="tablist"] { margin-bottom: 0; @@ -1390,17 +1390,24 @@ starlight-tabs .tab [role="tab"]:not([aria-selected="true"]):hover { z-index: 1; } .sl-markdown-content - starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child) - > section[role="tabpanel"] { + starlight-tabs:has(> [role="tabpanel"] > .expressive-code:first-child) + > [role="tabpanel"] { margin-top: 0; } .sl-markdown-content - starlight-tabs:has(> section[role="tabpanel"] > .expressive-code:first-child) - > section[role="tabpanel"] + starlight-tabs:has(> [role="tabpanel"] > .expressive-code:first-child) + > [role="tabpanel"] > .expressive-code:first-child :is(.frame, figcaption.header, pre) { border-top-left-radius: 0; } +/* Inside a docked group the active tab already names the block (YAML / JSON + / a package manager) — the corner badge would just echo it. */ +.sl-markdown-content + starlight-tabs:has(> [role="tabpanel"] > .expressive-code:first-child) + .frame[data-wh-lang]::after { + content: none; +} /* ---------- Small chrome the design system hadn't reached ---------- */ From 850e48cfa052c4d415310de498b28696136acaf3 Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Wed, 10 Jun 2026 12:59:05 -0400 Subject: [PATCH 20/23] =?UTF-8?q?fix(docs):=20round-5=20polish=20=E2=80=94?= =?UTF-8?q?=20seams,=20stale=20fades,=20tab=20chrome,=20wide-line=20sweep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Response to PR-review feedback + automated content/visual sweeps: - Table-card seam (review screenshot): the cluster-stroke padding-block applied to ALL scrollers — scoped to diagram scrollers only. - Zoom modal: stage edge-fade attrs cleared on every open — a prior 1:1 pan left a phantom fade on the next fitting diagram. - Docked-group chrome settled: no title=/filename comments inside format tabs (prose names the file, tab names the format); configuration's Environment tab is dotenv-style ini (was a bash terminal frame with stoplights + centered filename); dev .env block keeps its shell form minus the title. - Missing cfg tab groups added: access-control policy-bootstrap fragment (YAML/Environment), pipes .sql-seeding fragment (pipes.md -> .mdx). - Wide-line content sweep (subagent + audit): api.md single-line JSON responses pretty-printed; >100ch comments/log lines wrapped manually across deployment/architecture/sdk pages (WARN/ERROR examples use hanging indents under wrap=false); the SSE data: lines stay wire-accurate single lines (a broken continuation line would misrepresent the SSE format) and wrap visually instead. - External-link markers: prev/next pagination cards excluded (the Next card to GitHub grew a stray glyph), footer brand sentence excluded (mid-phrase arrow split the sentence); slim footer wraps deliberately below ~86rem (left-aligned links row) instead of ragged overflow. - sdk/reference E2E comment rewritten comment-above-command (the agent fix had reintroduced the deep-indent continuation pattern that overflows under preserveIndent on phones). Verified: visual QA sweep 19 pages x dark (+light spots) x DPR2 — one finding (footer wrap) fixed; phantom fades zero; zoom open/close/reopen clean; 73 table wraps seam-free; 30 diagram-edge crops uncut; edited pages re-checked clean at 320/390/768. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 +- docs/src/components/Footer.astro | 16 +++- docs/src/components/MermaidZoom.astro | 7 ++ docs/src/components/ScrollHints.astro | 8 +- docs/src/content/docs/access-control.mdx | 18 ++++- docs/src/content/docs/api.md | 5 +- docs/src/content/docs/architecture.md | 5 +- docs/src/content/docs/configuration.mdx | 77 ++++++++++--------- docs/src/content/docs/deployment.md | 24 ++++-- docs/src/content/docs/development.md | 3 +- docs/src/content/docs/{pipes.md => pipes.mdx} | 15 +++- docs/src/content/docs/sdk/queries.md | 8 +- docs/src/content/docs/sdk/reference.md | 4 +- docs/src/styles/global.css | 12 ++- 14 files changed, 137 insertions(+), 67 deletions(-) rename docs/src/content/docs/{pipes.md => pipes.mdx} (97%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8563968a..a3b77926 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -146,7 +146,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Config and policy examples in every supported format, plus reference-page chrome odds and ends** (`docs/src/content/docs/{configuration.mdx,access-control.mdx}` (renamed from `.md`), `docs/src/content/docs/{architecture,development,pipes,claude-code}.md`, `docs/src/styles/global.css`): the example config file is now YAML / Environment synced tabs (`syncKey="cfg"`; every `WH_*` name cross-checked against `internal/config/config.go` — the config FILE is YAML-only by struct tags, so no JSON tab there), and the complete policy example is YAML / JSON synced tabs (`syncKey="policy"`; the JSON twin machine-verified equal to the YAML). Code frames that represent real files gained `title=` headers (`config.yaml`, `policy.yaml`, `.env`, `~/.claude.json`, …) and every frame — terminals included — now shows a quiet language badge at its top-right (inside the title bar where one exists; ScrollHints copies `pre[data-language]` up to the frame since CSS `attr()` can't cross elements; plain-text-family frames stay unlabeled, and the badge yields to the hover copy button on headerless frames). A follow-up round, on review feedback: every substantive policy fragment on the access-control page is now a YAML/JSON tab group (7 groups total, each JSON twin machine-parsed equal to its YAML), the configuration tables returned to separate YAML Key / Env Var columns (the stacked merge predated wide-table support; 4-column tables now ride the breakout track), code-format tab groups DOCK onto their code frame (flush bottom edge, shared corner — a floating segmented control above an unrelated box read as disconnected), external-link `↗` markers extend to prose and footer links (absolute http(s) hrefs are off-site by construction), and the diagram zoom chip became a proper masked stroke icon on a translucent blurred chip instead of a `⤢` text glyph. Bare issue references in prose became links (#97, #223, #224, #279). The mobile icon-only search trigger joins the menu button's circular control family (it rendered as an unstyled square), and external sidebar links (Contributing Guide, Discussions, …) carry a `↗` marker. +- **Config and policy examples in every supported format, plus reference-page chrome odds and ends** (`docs/src/content/docs/{configuration.mdx,access-control.mdx}` (renamed from `.md`), `docs/src/content/docs/{architecture,development,pipes,claude-code}.md`, `docs/src/styles/global.css`): the example config file is now YAML / Environment synced tabs (`syncKey="cfg"`; every `WH_*` name cross-checked against `internal/config/config.go` — the config FILE is YAML-only by struct tags, so no JSON tab there), and the complete policy example is YAML / JSON synced tabs (`syncKey="policy"`; the JSON twin machine-verified equal to the YAML). Code frames that represent real files gained `title=` headers (`config.yaml`, `policy.yaml`, `.env`, `~/.claude.json`, …) and every frame — terminals included — now shows a quiet language badge at its top-right (inside the title bar where one exists; ScrollHints copies `pre[data-language]` up to the frame since CSS `attr()` can't cross elements; plain-text-family frames stay unlabeled, and the badge yields to the hover copy button on headerless frames). A follow-up round, on review feedback: every substantive policy fragment on the access-control page is now a YAML/JSON tab group (7 groups total, each JSON twin machine-parsed equal to its YAML), the configuration tables returned to separate YAML Key / Env Var columns (the stacked merge predated wide-table support; 4-column tables now ride the breakout track), code-format tab groups DOCK onto their code frame (flush bottom edge, shared corner — a floating segmented control above an unrelated box read as disconnected), external-link `↗` markers extend to prose and footer links (absolute http(s) hrefs are off-site by construction), and the diagram zoom chip became a proper masked stroke icon on a translucent blurred chip instead of a `⤢` text glyph. A fifth round (review feedback + an automated content/visual sweep) then: scoped the cluster-stroke padding to diagram scrollers only (on table cards it had opened a page-bg seam between the card border and the header band), cleared the zoom stage's leftover edge-fade attributes on every open (a prior 1:1 pan session painted a phantom fade on the next fitting diagram), settled the docked-group chrome (no `title=`/filename comments inside format tabs — the lead-in prose names the file, the tab names the format; the configuration Environment tab became dotenv-style `ini` so it stops rendering as a titled terminal), added the missing `cfg` tab groups (access-control's policy-bootstrap fragment, pipes' `.sql`-seeding fragment — pipes.md → `.mdx`), pretty-printed the wide single-line JSON wire examples (api.md responses; the SSE `data:` lines deliberately stay wire-accurate single lines and wrap visually), manually wrapped >100ch comment/log lines across deployment/architecture/sdk pages (the WARN/ERROR log examples now break with hanging indents under `wrap=false`), excluded prev/next pagination cards and the footer brand sentence from the external-link `↗` (a second glyph inside the Next card read as broken), and made the slim footer wrap deliberately below ~86rem (links become their own left-aligned row; the squeezed pane can't fit the one-line bar there). Bare issue references in prose became links (#97, #223, #224, #279). The mobile icon-only search trigger joins the menu button's circular control family (it rendered as an unstyled square), and external sidebar links (Contributing Guide, Discussions, …) carry a `↗` marker. - **SDK docs split into six topic pages, with package-manager-synced install tabs** (`docs/src/content/docs/sdk/{index.mdx,queries.md,streaming.md,pipes.md,admin.md,reference.md}` (new), `docs/src/content/docs/sdk.md` (deleted), `docs/src/config/sidebar.ts`, `docs/src/components/Hero.astro`): the 806-line monolith becomes `/sdk` (overview: install, quick start, client setup, `Result`, section cards) plus `/sdk/{queries,streaming,pipes,admin,reference}`, under a nested "TypeScript SDK" sidebar group (which also keeps the llms.txt page ordering coherent — the LLM outputs read the sidebar). Topic-first URLs are the multi-language plan on record: when a second SDK language lands, the shared usage pages grow `` code tabs and each language gets its own setup/caveats page, so the topic URLs never churn. Installation became `` (pnpm / npm / yarn / bun / deno / CDN), and the landing hero's click-to-copy install chip now follows that synced choice — Starlight persists the selected tab label in `localStorage` under its syncKey, the hero script maps it to the matching one-liner (CDN and anything unknown keep the pnpm default). Every inbound link from other pages already pointed at bare `/sdk` (still the overview); the three intra-page `#streaming` anchors became `/sdk/streaming` links, and `starlight-links-validator` gates the rest. A follow-up adds an "Imports & Runtimes" section to the overview — import-line tabs synced to the same `pkg` choice (the CDN reader sees the esm.sh import everywhere, including a fully-tabbed Quick Start), Node CJS `require()` and the `WaveHouse` IIFE global documented from the shipped exports map, and a verified runtime-support note (streaming needs an `EventSource` polyfill in Node — the global is still flag-gated as of Node 26, checked empirically; non-streaming works on Node 18+). Each topic page points CDN readers at the section. - **Pre-push CI marker is tree-keyed, not commit-keyed** (`Makefile`, `.githooks/pre-push`, `.claude/hooks/agent-bash-gate.sh`, `AGENTS.md`). The `make ci → git add → git commit → git push` flow no longer false-positive-blocks — committing advances HEAD but doesn't change the tree CI validated. `make ci` writes `tmp/ci-passed-tree-` computed against a throwaway `GIT_INDEX_FILE` (so untracked files are captured without touching the real index); pre-push and the agent-bash-gate resolve each pushed ref's `^{tree}` and match. Skipped on CI runners (they don't push). `review-passed` stays commit-keyed — a new commit needs a new review. - **Worktrunk seeds new worktrees from main** (`.config/wt.toml`). Added `wt step copy-ignored` to `post-start` ahead of `make tools`, so `wt switch --create ` copies `.bin/` and the three `node_modules/` trees (~1 GB combined) from main instead of cold-starting `pnpm install --frozen-lockfile` × 3. `[step.copy-ignored] exclude` keeps `tmp/`, `data/`, and vitest's `coverage/` out. diff --git a/docs/src/components/Footer.astro b/docs/src/components/Footer.astro index 2927f7aa..651eae2b 100644 --- a/docs/src/components/Footer.astro +++ b/docs/src/components/Footer.astro @@ -241,14 +241,26 @@ const repo = "https://github.com/Wave-RF/WaveHouse"; letter-spacing: -0.02em; color: var(--wh-ink); } - /* push the links to the trailing edge of the content column */ + /* push the links to the trailing edge of the content column (gap sized so + the row + the external-link arrows still fit the prose column on one + line — 1.25rem tipped it into an awkward two-row wrap) */ .wh-footer__links { margin-inline-start: auto; display: flex; flex-wrap: wrap; - gap: 1.25rem; + gap: 1rem; font-size: 0.875rem; } + /* In the 72–86rem window Starlight squeezes the content pane below the + prose width and the single-row bar can't fit (needs ~700px, gets ~650) — + wrap deliberately: links become their own left-aligned row under the + brand instead of a ragged right-aligned overflow. */ + @media (max-width: 86rem) { + .wh-footer__links { + margin-inline-start: 0; + flex-basis: 100%; + } + } .wh-footer__links a { color: var(--wh-ink-muted); text-decoration: none; diff --git a/docs/src/components/MermaidZoom.astro b/docs/src/components/MermaidZoom.astro index 2f4e499d..7659d002 100644 --- a/docs/src/components/MermaidZoom.astro +++ b/docs/src/components/MermaidZoom.astro @@ -66,6 +66,13 @@ if (!overlay) buildOverlay(); const stage = overlay.querySelector(".wh-zoom__stage"); stage.replaceChildren(); + // The stage is reused across opens — a prior 1:1 pan session leaves + // its edge-fade attributes behind, which painted a phantom fade on + // the next (fitting) diagram. + stage.removeAttribute("data-wh-more"); + stage.removeAttribute("data-wh-more-start"); + stage.scrollLeft = 0; + stage.scrollTop = 0; // Re-id the clone rather than cloneNode-ing as-is: a Mermaid SVG scopes // its entire inline