feat(#425): Dashboard as a full-size main work surface, selected by stable id - #436
Merged
Conversation
Adds the pure selection contract #425's navigation API is built on, with no behaviour change yet: nothing constructs or reads MainSurfaceState. src/application/main-surface.ts owns the two mutually exclusive main-surface states (Query, or a Dashboard by stable id + presentation mode + optional focus target), resolves an open request against the active workspace's collection by exact id, re-validates a selection against committed truth, and maps a surface onto the UNCHANGED /sql route contract (the selected id is session state and never reaches the URL). The three outcomes a caller must distinguish are explicit: `ok`, `missing` (deleted — fall back to Query mode, never silently to another Dashboard), and `duplicate` (ambiguous ids must never be resolved by a guess). That exactly-one-match rule already existed inside `replaceDashboard`, so it is extracted as `findDashboardStrict` and shared rather than reimplemented — one definition of the invariant, not two that can drift. It lives in src/application/ rather than src/core/ because it resolves against the workspace aggregate and the dependency direction is workspace <- application <- UI; src/core/ may never import src/workspace/ (build/check-boundaries.mjs). Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Wires the main-surface session state into the controller, so any Dashboard in a
StoredWorkspaceV3 collection can be opened by stable id in View or Edit mode.
The surface hosts are still today's whole-root swap — that restructure is the
next commit; this one establishes the selection semantics.
`app.openDashboard(request)` replaces the no-argument version as the ONE
application-level entry point: it resolves by exact id, reports a missing or
duplicate id through the shared toast/diagnostic path without touching any
state, and never mutates the Dashboard merely by opening it. Re-opening the same
id in the same mode keeps the live viewer session instead of building a
duplicate one; only a focus target re-renders. `showQuerySurface()` centralises
returning to Query mode, and `openSavedQuery()` plus every existing
query-opening path (`actions.loadIntoNewTab`) switch back to it first, so a tab
can no longer open behind a visible Dashboard.
`app.mainSurface` is now the ONE writer of the route's surface/mode: the header
surface switch, the "Dashboard →" nav, the `g d`/`g w`/`g v`/`g e` shortcuts and
the Dashboard's own View/Edit control all delegate to the navigation API instead
of writing routes themselves. That is what makes a mode change retain the
SELECTED Dashboard — a control that wrote `{surface:'dashboard',mode}` itself
would re-resolve the collection's first entry. URLs are unchanged: the selected
id is session state and never appears in the URL, so Back/Forward inside the
Dashboard surface deliberately preserves the explicit selection rather than
re-deriving one.
Selection is re-validated against every committed workspace: a deleted or
newly-ambiguous selection falls back to QUERY mode (never silently to another
Dashboard) and rewrites the route so the URL cannot keep claiming a Dashboard
surface with no document. A workspace switch reaches the same code, which is
what clears a stale selection unless the new workspace carries the same id.
Two teardown fixes this exposes: sign-out now disposes the mounted Dashboard
surface and advances the renderer generation (it previously did neither, so a
late Dashboard callback still read as current behind the login screen), and the
generation is advanced on the surface TRANSITION rather than as a side effect of
a mount — mounts become conditional in the next commit.
Legacy entry points with no Dashboard chooser yet (#426 adds the tree) resolve
the compatibility Dashboard and then open it by id; an empty collection still
reaches the Dashboard surface so its "Create dashboard" state stays available.
Part of #425.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Behaviour-preserving groundwork for #425's two mutually exclusive main work surfaces. `mountWorkbenchShell` built the ENTIRE signed-in shell — header, sidebar, the workbench column, mobile nav — and mounted it with one `root.replaceChildren`, which is why a Dashboard could only ever replace the whole page and lose the sidebar with it. `ui/app-shell.ts` now owns the persistent frame: a header SLOT (so each surface can swap its own header without rebuilding the sidebar around it), the sidebar with its schema/library panes and splitters, the mobile bottom-nav, the one `app.dom = {}` reset, the six reactive effects that repaint those regions, and the catalog bootstrap-load tail. `.main-row` now holds `sidebar, sideHandle, queryHost, dashboardHost`; `mountWorkbenchShell` mounts the query column into `queryHost` and keeps only what belongs to it (tabs, toolbar, editor hosts, results region, the editor/results splitter, `updateEditorModeUi`, the tabs effect, `attachShell`, `syncSelection`). The Dashboard host is an empty hidden sibling — nothing drives it yet. The single three-axis drag context splits with them: the app shell owns `'col'` and `'sideRow'`, and its `rectFor` deliberately drops the `editorRegion!`/`resultsRegion!` branch, which would throw once a surface mounts without an editor at all; the workbench keeps `'row'` over its own two regions. Three CSS rules the unit suite cannot see, since happy-dom applies no CSS: `.app-header-slot { display: contents }` keeps `.app-header` itself the direct child of `#root`'s flex column (a plain wrapper would become the flex item and lose the header's `flex-shrink: 0`); the two hosts carry an explicit `[hidden] { display: none !important }` because a class rule's `display: flex` beats the UA stylesheet's bare `[hidden]`; and the mobile Tables view now hides the HOST rather than `.workbench` inside it, since the host — not the workbench — is the `flex: 1` sibling that would otherwise fight the full-width sidebar. No test file changed: the existing suite passing unmodified is the regression proof that the move is behaviour-identical. `renderApp`'s now-dead `toggleTheme` helper goes too — `buildAppHeader` reads `app.toggleTheme` off the live object. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
The two main work surfaces become mutually exclusive HOSTS inside the persistent shell rather than two competing whole-page renders (#425). Opening a Dashboard now replaces the complete SQL-editor-plus-result-drawer area while the left sidebar stays visible, and returning finds the Query surface exactly as it was — its editor contents, selection and scroll, active tab, result view, and result-drawer size all survive, because the host is hidden rather than destroyed. `AppShellHandle.showHost()` exposes one host and mirrors the choice onto `.main-row[data-surface]`. `renderDashboard(app, target)` now receives everything it needs — the host, the selected Dashboard id, the mode, and the focus target — instead of re-deriving any of it from the route or from collection position, and installs its header into the shell's shared slot. Its `app.dom = {}` reset is GONE: the Query surface stays mounted behind it and its DOM refs live in that same shared bag, so resetting it would strand every workbench and sidebar reference while the elements were still in the document. Three behavioural fixes this exposes, each an acceptance criterion: - A surface switch no longer calls `workbench.destroy()`. That aborts the in-flight request and issues KILL QUERY, and Dashboard mode must not cancel the currently open editor query merely because the surface changed. It stays on the real teardown paths (workspace switch, not-found/loading, sign-out). - A same-workspace Back/Forward is now a surface transition, not a teardown. It used to run `disposeCurrentSurface`, whose blanket control-disable would inert the still-mounted editor toolbar, tabs, and sidebar inputs permanently. - The shell handle is nulled wherever `#root` is replaced wholesale (login, and the not-found/loading renders), so a later sign-in re-mounts instead of skipping a mount that is no longer in the document. Leaving a Dashboard empties its host: the host outlives the surface, so a disposed viewer session must not leave a `.dash-page` lingering behind the Query surface. The Dashboard host is exposed BEFORE rendering, because the grafana-grid engine measures its host's width immediately after mount and a hidden host measures 0 — which would silently pin every Dashboard to the widest 12-column breakpoint. Returning to the Query surface repaints the results pane, since a Chart.js canvas built while that host was hidden has no laid-out size to auto-resize from. Both are invisible to happy-dom and were verified in Chromium. Adds the Dashboard surface toolbar — `[Back to query] · title · [View | Edit]` — with the View/Edit switch moved out of the primary toolbar and reflecting the RENDERED mode. The style, refresh, search and filter controls stay where they were. The doc pane is closed on a surface transition (it mounts on `document.body` and would float over the surface that replaced it). Surface assertions move from presence to exposure: both hosts are mounted now, so `expectSurface()` asserts which host is `hidden`, that `.dash-page` is genuinely absent in Query mode, and that `.main-row[data-surface]` agrees — a strictly stronger contract than the old "the other surface's DOM is gone". Mobile keeps #248's full-bleed Dashboard by hiding the sidebar and bottom nav for `[data-surface="dashboard"]`, and the three e2e fixtures that boot the real app or hard-code `#root > .dash-page` now mirror the production nesting. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Completes #425's navigation contract: `openDashboard({ …, focus })` can now land on one panel tile or one curated filter of the Dashboard it opens. Focus is delivered at the point where the node it names actually exists and is stable — straight-line sequencing off real completion signals, never a timeout: - A TILE card exists before the first wave: the viewer session seeds its state with every tile at construction, so the render effect built every card synchronously, and the host is in the document by then (without which `focus()` is a silent no-op). Focus lands on the card, never an inner heading, because a publish replaces tile bodies. - A FILTER field is resolved AFTER `session.start()`. The first publish changes the bar's signature (committed values, active flags, arriving options) and a rebuild replaces the whole control, so focus set earlier would be dropped onto a detached node — caught by a test, not by inspection. A tile resolves by its Dashboard-local TILE id, never the saved-query id it renders, through the render closure's own card/KPI-host maps. A filter resolves filter id → declared parameter → control, within the SELECTED Dashboard's filters only. That needs a DOM handle the bar did not expose: `data-field-key` is now stamped on every field root (at the one composition point, not in each of the four build branches) and `FilterBarHandle.fieldElement(key)` resolves it. Two details that only testing surfaced: the lookup must search `timeEl`/`ordinaryEl` rather than `el`, because the caller re-parents those regions into separate toolbars and leaves `el` empty; and a parameter OWNED by a time-range group has no standalone field, so it resolves to that group's compound control instead of wrongly reporting "no such filter". The target gets `tabindex="-1"` only if it has none — programmatic focus without joining the Tab order — is scrolled into view inside the existing `.dash-page` scroll container, and carries a temporary `.is-nav-target` highlight IN ADDITION to the normal focus ring, cleared after a bounded interval or on the next pointerdown/keydown, whichever comes first. A prior render's pending highlight is retired by the next one (and by surface teardown) so it can never fire against a detached node. A missing tile or filter id opens the Dashboard anyway and reports a non-destructive diagnostic; a request from a superseded render is dropped via the existing generation check. The unit tests spy `Element.prototype.scrollIntoView` (happy-dom implements it as an empty method) and attach the fixture root to the document, since happy-dom — like a real browser — refuses focus on a disconnected element. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Covers the acceptance criteria the state-level tests could not reach, each written to fail against the pre-#425 whole-root swap rather than merely describe the new behaviour: - a Dashboard owns the whole work area (the query host is `hidden`, so no invisible result drawer consumes layout space) while the sidebar stays visible and its schema list stays connected; - a round trip preserves the Query surface by IDENTITY — the same `sqlEditorHost`/`resultsRegion` nodes, the same CM6 view in that host, the editor/results split height, the active tab, and the editor text. Identity is the point: a reconstructed surface would look identical and still fail here; - a surface switch never calls `workbench.destroy()` (which aborts the in-flight request and issues KILL QUERY), while sign-out still does; - repeated switching leaks no second workbench, sidebar, header, editor view, or leftover `.dash-page`; - a same-workspace Back navigation leaves the still-mounted Run button and schema search enabled — the path that used to run the blanket control-disable. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Three writers still targeted `dashboards[0]` through #424's compatibility slot. That was invisible while the UI exposed exactly one Dashboard, but #425 makes a non-first Dashboard selectable AND leaves the Library pane visible beside it, so each one becomes a silent wrong-target write — contradicting the acceptance criterion that a commit replaces only the selected Dashboard. - **Export Dashboard…** exported the collection's first entry from a different Dashboard's own File menu. It now resolves the selected id. - **Import Dashboard** (via `reloadDashboardRoute`) overwrote slot 0 while another Dashboard was on screen. It now folds the projection back by id through `replaceDashboard`, which returns null for a missing or ambiguous id — leaving the collection untouched rather than guessing, with the surface reconciling to Query mode on its next projection. The compatibility slot is still written when nothing is selected (the legacy entry point). - **The favourite star** drives tile membership through the favourite↔membership coupling that writes the compatibility Dashboard, and rewiring that is #427's job, not this issue's. So it is GATED: starring is refused with an explanation while a non-first Dashboard is selected, instead of adding a tile to a Dashboard the user is not looking at. Query mode and a selected first Dashboard behave exactly as before. Filed as an inbox issue for #427 to retire along with the coupling. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
CHANGELOG `[Unreleased]` gains the user-visible entry, and ADR-0003 gets an addendum amending its surface lifecycle: its original consequence — that route resources are disposed when switching surfaces, and the Workbench shell remounts — described a model where each surface owned the whole page. #425 replaces that with one persistent shell owning `#root` and two sibling hosts, exactly one exposed, so a Dashboard owns the complete editor-plus-results area while the sidebar stays visible and the Query surface survives a round trip. The addendum also records what the amendment forbids (a surface switch must not `workbench.destroy()`, and every path replacing `#root` must forget the shell handle), that selected-Dashboard state is session state keyed by stable id with routes unchanged, and two consequences a reader would otherwise mistake for omissions: Edit reuses the single filter bar, and the schema tree is no longer refetched on a Dashboard round trip. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
…f screen
Caught by live verification in Chromium at 375px, which the unit suite cannot see
(happy-dom applies no CSS). `.dash-toolbar > * { flex-shrink: 0 }` pins every
toolbar child — correct for the style/filter controls, which must not squash — so
the new surface toolbar's title grew to its full content width (521px of a 375px
row) and shoved the View/Edit switch to x=774, off screen behind a horizontal
toolbar scroll.
The title is now the one control in that row allowed to shrink, which needs
two-class specificity to beat the `> *` rule. Measured after the fix: the row no
longer overflows (scrollWidth == clientWidth == 375), the title ellipsizes, and
View/Edit ends at x=365.
Part of #425.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
Code review of the branch surfaced two regressions this series introduced, both reachable from shipped UI in a single tab. **A committed workspace that dropped the selected Dashboard wedged the app.** `applyCommittedWorkspace` reconciled the selection to Query mode and rewrote the route, but did not render — and both callers that would repaint (`afterLibraryChange`, `runWorkspaceRefresh`) branch on `sqlRoute.surface`, which the fallback had just changed under them. So the deleted Dashboard's DOM stayed exposed while state and route agreed Query mode was active, which made every route back a no-op: `showQuerySurface`, the header switch, `g w` and a Library click all early-return on exactly that agreement. Import Dashboard in `copy` mode reaches it directly, since it mints a fresh id and the previously selected one disappears. The fallback now completes with a render. **A write resolving just after sign-out re-mounted the signed-in shell over the login screen.** Sign-out advances the surface generation (so a late Dashboard callback cannot settle against a replacement renderer) but deliberately keeps the projected workspace for the next sign-in — which let `refreshCurrentSurfaceAfterStale` take its "refresh the ready surface" branch with no credentials. It now also requires `conn.isSignedIn()`. Related teardown hole: the Dashboard disposal and generation bump move from `signOut` into `renderLoginApp`, the one place that knows the login screen is showing. `onAuthLost` (a 401 or expired token) arrives there without passing through `signOut`, and previously left the viewer session, the window listeners and a generation-matching `surfaceCommands` alive — so Dashboard refresh and style shortcuts stayed dispatchable from Login, against a dead session. Also from the review: - **Import Dashboard** replaced the compatibility slot even when invoked from a different Dashboard's own File menu. `planImportDashboard` takes an explicit target id (falling back to the compatibility slot when it names nothing), which is the counterpart of the Export fix in the previous commit. - `state.dashboard` was always the compatibility projection, so `reloadDashboardRoute` folded the FIRST Dashboard's document into the SELECTED slot — producing a duplicate-id collection. The projection now follows the selection. The two tests that "covered" this were fabricating a `state.dashboard` production cannot produce; they now project through the real path. - A focus target was re-delivered on every later repaint of the same selection, so an external commit or a style switch yanked focus back to that tile minutes later. `withoutFocus` — written for exactly this and never called — now consumes it on delivery. A late filter focus also yields to a user who has already interacted, since it lands only after the opening wave resolves. - **Create dashboard** now opens what it created by id instead of re-rendering an unselected surface, so the session stops reporting Query mode with a Dashboard on screen. The new id rides back through the mutation's own `data` channel. - `.is-nav-target` becomes a `box-shadow` ring: an element has one outline, so the previous rule replaced the focus indicator instead of adding to it. - The mobile fixture had no `.sidebar` or `.mobile-nav`, so the full-bleed rules were unexercised, and its `__app` stub still carried the removed `openDashboard` while lacking the two seams the header now calls. Both fixed, with new specs asserting the sidebar is hidden on mobile and still beside the Dashboard at desktop widths. - New coverage for the acceptance criterion an index-0 fixture cannot test: a commit driven from a NON-FIRST selection advances only that entry and leaves the first byte-identical. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
…kspaces Two blocking review findings, both cases where a selected Dashboard could be silently retargeted — exactly what #425 forbids. **An explicit import target now fails closed.** `planImportDashboard` fell back to `withCompatibilityDashboard` whenever `replaceDashboard` returned null, so an import invoked from Dashboard B whose target had been deleted concurrently — or whose id had become ambiguous — quietly overwrote `dashboards[0]`, destroying a Dashboard the import never named. It now returns a `dashboard-import-target-stale` diagnostic and commits nothing; only the no-target legacy path still writes the compatibility slot. Worse than the bug: a test encoded the unsafe fallback as expected behaviour ("falls back to the compatibility slot for an unknown target"), making it normative. That test is replaced by two that assert nothing is committed and no stored Dashboard is touched — for a deleted target AND an ambiguous one. **A workspace switch now always clears the selection.** A Dashboard id is unique WITHIN a workspace, not globally, so two workspaces can each hold a Dashboard called `main`. `reconcileMainSurface` kept the selection whenever the incoming workspace contained that id, which meant switching workspaces silently opened an unrelated Dashboard — and the next edit would have committed to the wrong resource. `applyCommittedWorkspace` now computes `workspaceChanged` BEFORE reconciling and clears on a change, re-validating only same-workspace projections. The route rewrite and render stay scoped to a selection lost within one workspace; a switch is left to its own URL-driven path, which resolves the new workspace's own Dashboard when the route asks for one. The `main-surface` selection test fixture was papering over this: it assigned `currentWorkspace` directly, so `state.workspaceId` stayed empty and every first projection read as a switch. It now projects through `applyCommittedWorkspace`, which is also what makes the same-workspace and cross-workspace cases distinguish. Both fixes verified by reverting each in turn and confirming the new tests fail. Part of #425. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1
This was referenced Jul 25, 2026
BorisTyshkevich
added a commit
that referenced
this pull request
Jul 25, 2026
… branch #436 merged first, so this branch takes main and keeps both sides of the one conflicting hunk (CHANGELOG's `### Added`). `src/styles.css` merged cleanly at the text level and the suite is green apart from three deliberate failures: the type/radius gate this branch adds now reports #425's new `.dash-surface-title` (`font-size: 13px; font-weight: 600`) and `.is-nav-target` (`border-radius: 6px`) — the gate doing exactly its job on code written while it did not yet exist. The next commit resolves them.
BorisTyshkevich
added a commit
that referenced
this pull request
Jul 25, 2026
#436 was written before the gate in this branch existed, so its new CSS reached main untokenized. Four defects, each of a kind this branch already documented. **Type.** `.dash-surface-title` was `13px/600` — half a pixel above `--text-body`, a step below the One-Pixel Floor and therefore one nobody can see, in a row whose `Back to query` and `View | Edit` controls sit at `--text-label`. It steps up by WEIGHT now (`--text-body`/`--fw-semibold`), per the Compact Scale Rule: a toolbar is not a heading's own space. Measured side by side in Chromium — the 0.5px is invisible, the weight is not. **Radii.** `.is-nav-target` forced `border-radius: 6px` onto whatever it marked, so a `--r-md` dashboard tile visibly CHANGED SHAPE while highlighted and the ring mitred against the corner it was pointing at — the same defect this branch retired from the KPI accent bar. It sets no radius now; a box-shadow already follows the target's own. **Elevation.** That highlight mixed its own translucent accent, `in srgb … 25%`, beside the ring family's `in oklab … 22%` — one halo in two spellings, 3% apart before anyone had a reason. It is `--ring-nav`, a fourth member of that family, documented as the one that marks "you were sent here" rather than focus. **And it painted nothing at all** on a flow-band KPI: those are addressed through `.dash-kpi-member`, which is `display: contents` and generates no box, so navigating to one marked it invisibly. The rule reaches through to the card, as the neighbouring `.dash-drop-target` rule already has to. The focus/scroll half of that bug is #425's behaviour contract and is filed as #438, not patched here. Also: a dead `gap: 8px` on `.dash-surface-toolbar`, overridden by `.dash-toolbar`'s later, equally specific `gap: 10px` since the day it landed. ## The gate had three holes, which is why the above shipped - The shadow check tested for `rgba(` only, so a hand-mixed `color-mix(…, transparent)` sailed through. It now rejects any box-shadow that builds its own translucent colour, and a companion test pins every ring halo to one colour space at one alpha. - The unstyled-class check reads a curated file list, and #425 split `app.ts` into `app-shell.ts` + `workbench/workbench-shell.ts` — moving the application frame, the sidebar and both surface hosts out of its view. The list follows the markup. Each is mutation-proven: reverting #425's exact CSS fails the radius and shadow tests, drifting `--ring-nav`'s halo fails the new one, and an unstyled class in `app-shell.ts` fails the coverage one. ## One unrelated regression this branch already owned `splitters.spec.js` had been failing here, unrun, since c03978e moved the resize indicator off the layout path: a `3px` bar held down by `scaleX(.34)` painted 1.02px and reported `3px` to anything reading the computed box. It scales UP from an honest `1px` now, and the spec measures the painted extent (layout × the transform's own scale) rather than the untransformed box. Verified: 5244 unit tests, 100% statements/lines · `tsc --noEmit` clean · build clean · detector 0 across `src/` · Playwright 298 passing, the 2 remaining WebKit failures reproducing on untouched `origin/main` (#439). Both themes and the highlight on tiles, flow-band KPIs and filter fields checked in real Chromium. Refs #425. Filed #438, #439.
Merged
7 tasks
BorisTyshkevich
added a commit
that referenced
this pull request
Jul 25, 2026
…detector findings to 0 (#433) * feat(design): ship the brand typefaces, tokenize the type scale, fix AA contrast The type system had no tokens, so there was nothing to drift from. 231 literal font-size declarations had grown into 22 distinct values — eleven of them inside a 4px range, five below the documented 11px floor, with 9/9.5, 10/10.5 and 13.5/14/14.5 all coexisting. At these sizes a 0.5px step buys ~0.26px of x-height: below one device pixel at 1x, and smaller than the variation between platform fallback faces. Those were not tiers, they were drift. Fonts now actually ship. DESIGN.md has always named Inter and JetBrains Mono, but with no @font-face and no CDN allowed (hard rule 4), they rendered only for users who happened to have them installed — everyone else silently got the platform UI face and Menlo/Consolas. build/fonts.mjs inlines latin-subset, upright, variable-weight woff2 as base64 @font-face sources: 88,660 bytes of woff2, +19.6% gzip on the artifact, still zero third-party requests. unicode-range is kept so Cyrillic/CJK cells and the glyphs outside the subset keep deferring to the platform font instead of rendering tofu. Contrast: --fg-faint met no accessibility bar in either theme (2.55:1 light, 3.10:1 dark at worst) while carrying most of the smallest text in the product. Fixing it surfaced a second failure — ClickHouse Blue as *text* is 4.10:1 on light chips and 3.83:1 on dark surfaces — so text takes a new --accent-text (the palette's existing #005F8A in light, a lifted #2596CC in dark) while fills, rings, carets and icons keep --accent, where the 3:1 non-text bar applies. Six surfaces shipped with no CSS rule at all and therefore rendered in browser chrome, not in anything plain. The linked-tab conflict chooser (#343) — the dialog deciding whether to overwrite work saved in another tab — had 13.333px Arial buttons with 2px outset borders and a title visually identical to its description. The query-tab marker warning that a linked query changed rendered as an unstyled stray '!'. workspace-not-found had a 32px h1 and a raw #0000EE underlined link. It had a passing unit test throughout: behaviour coverage cannot see a missing stylesheet, which is why the contract now has its own gate. tests/unit/typography-contract.test.js asserts every font-size resolves to a token, no two steps within a ramp sit closer than 1px, tokens match the DESIGN.md frontmatter, token contrast clears AA in both themes, no class the UI renders is left unmatched by CSS, and the inlined fonts stay inside a byte budget. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * feat(design): tokenize radii, elevation and the semantic/graph/syntax palettes Same failure mode as the type ramp, three more times over: a documented scale, no tokens to hold it, and drift filling the gap. Radii — fourteen values (2/3/4/5/6/7/8/9/10/11/12/16/18/20px) against a documented four-step scale, with 4/5/6/7px all in play for the same kind of control and `.dash-tile` at 10px while DESIGN.md's own Dashboard Tiles section said 8px. Now --r-xs/sm/md/lg chosen by what KIND of surface a box is, plus --r-pill for capsules: `border-radius: 18px` silently stops being a capsule once the box passes 36px tall, and three chips were written that way. Elevation — DESIGN.md documents four shadow entries whose whole job is to explain physical placement. The stylesheet had fourteen distinct shadows across eleven black alphas, so two popovers could differ for no reason. One token per documented entry plus --shadow-float for transient surfaces, --scrim for backdrops, and --ring/--ring-warn/--ring-error so a focus ring states which KIND of state has focus rather than only that something does. Colour — three genuine sub-palettes were living as scattered hex. --kind-* and --role-* (EXPLAIN/schema graph object kinds) were three separate copies of each hex across node fill, edge and legend swatch, free to drift apart while claiming to mean the same thing. --sql-* (editor theme) had five [data-theme='light'] rules that are now redundant and deleted. All are documented in DESIGN.md. Two real bugs fell out of the new gate rather than out of review: • `.script-cell.rows:hover { background: var(--hover) }` — --hover has never existed, so the hover affordance on a clickable cell resolved to nothing and simply never appeared. • `var(--success, #238636)` / `var(--danger, #cf222e)` referenced tokens that were NEVER DEFINED, so KPI delta and export-status colours bypassed the theme entirely and quietly failed AA (#238636 is 3.92:1). A hex fallback turns a missing token from a visible bug into an invisible one, so the contract now forbids the pattern outright and separately asserts every var() resolves. Also: `.cancelled-badge` hard-coded #ef4444 on a 12% tint of itself — 3.23:1 in light theme — now --error-fg at 5.29:1; and three dead --bg-panel/--bg-sidebar fallback chains removed (neither token has ever been declared). Semantic and log-level colours are checked against the surfaces they can ACTUALLY land on, not the full cross-product: testing every token against every background reports failures no user can reach (a log level never renders on a chip) and pressures a correct palette into changing for nothing. Detector on src/styles.css: 149 findings -> 7. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * fix(design): retire the accent stripes, keep two hot surfaces off the layout path The last seven detector findings, none of which were drift — each was a real design or performance decision that had never been made explicitly. KPI cards carried `border-top: 3px solid var(--kpi-accent)` on an 8px radius. Two problems, and the second is the one that matters. The thick top edge mitres visibly where it tapers into the 1px sides — but more importantly a coloured stripe across a metric card is the single most over-used shape in the category, and PRODUCT.md lists decorative dashboards as an anti-reference. The colour is real (kpi-panel.ts sets --kpi-accent from the query spec's `presentation.color`, which the schema calls a per-KPI colour hint), so it stays — as a tint on the card's own 1px frame, which says the same thing without the cliché. --kpi-accent now defaults to --border instead of --accent: an author who supplies no hint gets a neutral card, so ClickHouse Blue is scarce again instead of framing every KPI in the grid. My first attempt here moved the stripe from a border to `box-shadow: inset 0 3px 0` and the detector flagged it again — correctly. That fixed the mitre and kept the pattern, which was solving the wrong half of the problem. Reference-doc admonitions had the same shape: a 3px left stripe on a rounded, tinted callout. They now state their variant with a 1px frame in the variant colour plus a coloured title word — colour paired with text, which is what the Evidence Rule actually asks for. Blockquotes keep the left-rule convention at hairline weight, so they stop competing with the admonitions that need noticing. Two layout-thrashing transitions, both on high-frequency surfaces: • the streaming progress strip transitioned `width` on every progress event — now full-width with a left origin, scaled by results.ts; • the column/row resize handles transitioned width/height 1px->3px on hover, and they sit on every column edge — now a fixed 3px bar scaled down at rest. The Altinity logomark moved to src/ui/brand-logo.ts. Its 26 gradient stops are a supplied brand asset: fixed by the brand, deliberately outside the DESIGN.md palette, and never to be borrowed for UI chrome. Giving it its own module lets .impeccable/config.json exclude exactly that one file from the colour check rather than silencing all ~60 real icons in icons.ts — and it unclutters a file where one 4 KB string dwarfed sixty small paths. Detector across the whole src tree: 0 findings (195 before this branch). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * fix(design): repair the light-theme syntax palette and the destructive fill Two review blockers, both mine, both in the token pass. 1. The light theme's five --sql-* definitions were self-referential: `--sql-keyword: var(--sql-keyword)`. A custom property that references itself forms a cycle, and per spec that does NOT fall back to the earlier :root value — the computed value becomes invalid, and the .sql-* rules consume these without a fallback. So light-mode keywords, functions, strings, numbers and comments lost their colour entirely and inherited the editor foreground. Cause: the script that pointed hex literals at the new tokens split the file at a marker inside :root, so the light theme block was still in its "rewrite" half and its own definitions got rewritten into references to themselves. I hit exactly this on --ring-error earlier in the same pass, fixed it there, and then verified with a grep scoped to :root — which is why this survived. Neither existing check could see it: the token WAS defined, and every var() reference DID resolve. A cycle needs its own detector, so there is now one, built per declaring block (the same name is legitimately redefined in :root and in each theme) and catching indirect loops as well as direct self-reference. 2. `.cf-overwrite` used --error-fg as a button FILL with white text. A foreground token is tuned to be legible as TEXT on its own plane, so dark theme's #f87171 gave white 2.77:1 — nowhere near AA, on the button that destroys another tab's saved work. New --error-fill is dark enough to carry white in both themes (4.98:1 dark, 6.47:1 light) and still clears 3:1 against the dialog behind it so the button's own edge stays visible (WCAG 1.4.11). Light reuses #B91C1C, so only dark needed a new value. The old test asserted white-on-accent and white-on-error-fg for the LIGHT theme only, which is precisely how this shipped. Enumerating pairs by hand is what failed, so the replacement derives them: it scans the stylesheet for every rule putting white on a tokenized background and checks each in BOTH themes, with a guard test so a regex that stops matching cannot silently pass on an empty set. Both new tests were verified to fail on the reintroduced bugs — the cycle detector on the restored self-reference, and the fill check reporting exactly the 2.77:1 the review cited — then pass on the fix. Light-mode highlighting confirmed rendering in the browser: #AF00DB / #795E26 / #A31515 / #098658 / #008000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * fix(deploy): permit data: under font-src, or the inlined fonts never load The CSP blocked both typefaces this branch went to the trouble of inlining. `deploy/http_handlers.xml` and `deploy/nginx/default.conf.template` both send `font-src 'self'`, and `'self'` is an origin match — it does not cover the `data:` scheme, which has to be listed explicitly. The neighbouring `img-src data:` is there for exactly that reason. Verified rather than reasoned about: served the real dist/sql.html under the exact policy string read from http_handlers.xml and got, twice, Loading the font 'data:font/woff2;base64,…' violates the following Content Security Policy directive: "font-src 'self'". The action has been blocked. with document.fonts reporting status "error" for both families and both measuring identical to the generic-family sentinel — i.e. not applied. What makes this worse than an ordinary missing-config bug is that it fails invisibly. A blocked @font-face renders no tofu: the stack falls through to -apple-system / Menlo and produces perfectly acceptable text. That is bit-for-bit the bug this branch exists to fix, so the deployed app would have gone on rendering platform fonts, looked entirely correct, and left no evidence but a console violation. Meanwhile every local check passed — `npm run local`, a static server and file:// all serve the artifact with no CSP at all, because the policy lives only in the deploy configs. My earlier "verified in the browser" was true and worthless: it never went through a server that sends the header. So the durable fix is the test, not the token. tests/unit/csp-contract.test.js asserts every URL scheme the BUILT ARTIFACT references is permitted by the governing directive, in every config that ships a policy, and that those configs agree on everything but connect-src. It scans the whole artifact rather than just the stylesheet — the favicon lives in build/template.html and CodeMirror injects a `background-image: url(data:…)` from the JS bundle, so a CSS-only scan would have reported a false all-clear. Generalizes past fonts to a future data: image, blob: worker or remote reference. Verified to fail on the reintroduced bug (`font-src must list data:`) and on one-config-only drift. Also from review: • No `font-display`. It describes the display timeline for a NETWORK fetch, and a data: source has none — the face is present when the inline <style> parses. `swap` asserted a load phase that does not exist and requested a flash of fallback that cannot happen; the default is both honest and better behaved. • The in-binary license metadata is only partial, and the review's hypothesis was half right: fontsource's subsetting keeps nameID 0 (Copyright) and nameID 14 (License Info URL) but drops nameID 13 (License Description). Confirmed with fontTools rather than assumed. No compliance gap in the distributed unit, though — dist/sql.html already carries the full OFL text and both copyright lines in its leading notices comment, which is now asserted so the embedding cannot silently regress. • Stale filename in build/fonts.mjs (`typography-contract.test.ts` -> `.js`). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * fix(design): keep --text-nano off DOM text, and enforce the tier restrictions DESIGN.md's Zoomable-Surface Exception reserves --text-nano (9px) for SVG text inside the EXPLAIN and schema graphs — surfaces that pan and zoom, and whose layout is measured against that size — and states the floor for anything read without zooming is --text-micro. Two DOM elements were using it anyway: • .mnav-badge, the mobile results-count badge, was `font-size: 9px` before the ramp collapse, and the mechanical 9px -> --text-nano mapping carried it onto the SVG-only tier without anyone deciding; • .qtab-external, the !/⌫ conflict-state <span>, I authored at --text-nano in the same commit that wrote the rule forbidding it — while DESIGN.md assigns state badges to --text-micro and my own rule comment called it a state badge. Both are now --text-micro. Verified under the 768px breakpoint (the badge rule is mobile-only, so measuring at desktop width reads a UA default and proves nothing): 14x14 and 15x15 boxes, no overflow, and a three-digit count stays inside the viewport. The deeper problem is that the contract could not see this. Its checks verified a declaration uses *some* --text-* token, which is syntax; a tier whose entire purpose is "only here" needs the "only here" asserted. So the restrictions are now mechanical, all three of them rather than just the one reported: • --text-nano only on .explain-graph / .schema-graph selectors; • --text-mark only on the login lockup, --text-metric* only on the KPI value — this is "The Compact Scale Rule" (nothing in the shell exceeds --text-title) made enforceable instead of aspirational; • the document ramp only on Read surfaces (.md-view, .docs-*, .login-h1), so prose sizes cannot leak into operable chrome. Each verified to fail on a reintroduced violation, naming the offending selector: `.qtab-external` for the nano rule, `.logo-name` for a doc-ramp leak. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF * fix(design): bring #425's Dashboard surface onto the token system #436 was written before the gate in this branch existed, so its new CSS reached main untokenized. Four defects, each of a kind this branch already documented. **Type.** `.dash-surface-title` was `13px/600` — half a pixel above `--text-body`, a step below the One-Pixel Floor and therefore one nobody can see, in a row whose `Back to query` and `View | Edit` controls sit at `--text-label`. It steps up by WEIGHT now (`--text-body`/`--fw-semibold`), per the Compact Scale Rule: a toolbar is not a heading's own space. Measured side by side in Chromium — the 0.5px is invisible, the weight is not. **Radii.** `.is-nav-target` forced `border-radius: 6px` onto whatever it marked, so a `--r-md` dashboard tile visibly CHANGED SHAPE while highlighted and the ring mitred against the corner it was pointing at — the same defect this branch retired from the KPI accent bar. It sets no radius now; a box-shadow already follows the target's own. **Elevation.** That highlight mixed its own translucent accent, `in srgb … 25%`, beside the ring family's `in oklab … 22%` — one halo in two spellings, 3% apart before anyone had a reason. It is `--ring-nav`, a fourth member of that family, documented as the one that marks "you were sent here" rather than focus. **And it painted nothing at all** on a flow-band KPI: those are addressed through `.dash-kpi-member`, which is `display: contents` and generates no box, so navigating to one marked it invisibly. The rule reaches through to the card, as the neighbouring `.dash-drop-target` rule already has to. The focus/scroll half of that bug is #425's behaviour contract and is filed as #438, not patched here. Also: a dead `gap: 8px` on `.dash-surface-toolbar`, overridden by `.dash-toolbar`'s later, equally specific `gap: 10px` since the day it landed. ## The gate had three holes, which is why the above shipped - The shadow check tested for `rgba(` only, so a hand-mixed `color-mix(…, transparent)` sailed through. It now rejects any box-shadow that builds its own translucent colour, and a companion test pins every ring halo to one colour space at one alpha. - The unstyled-class check reads a curated file list, and #425 split `app.ts` into `app-shell.ts` + `workbench/workbench-shell.ts` — moving the application frame, the sidebar and both surface hosts out of its view. The list follows the markup. Each is mutation-proven: reverting #425's exact CSS fails the radius and shadow tests, drifting `--ring-nav`'s halo fails the new one, and an unstyled class in `app-shell.ts` fails the coverage one. ## One unrelated regression this branch already owned `splitters.spec.js` had been failing here, unrun, since c03978e moved the resize indicator off the layout path: a `3px` bar held down by `scaleX(.34)` painted 1.02px and reported `3px` to anything reading the computed box. It scales UP from an honest `1px` now, and the spec measures the painted extent (layout × the transform's own scale) rather than the untransformed box. Verified: 5244 unit tests, 100% statements/lines · `tsc --noEmit` clean · build clean · detector 0 across `src/` · Playwright 298 passing, the 2 remaining WebKit failures reproducing on untouched `origin/main` (#439). Both themes and the highlight on tiles, flow-band KPIs and filter fields checked in real Chromium. Refs #425. Filed #438, #439. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This was referenced Jul 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Closes #425.
A Dashboard becomes a full-size main work surface selected by stable id. Opening one replaces the complete SQL-editor-plus-result-drawer area — the left sidebar stays visible — and returning finds the Query surface exactly as it was.
Before this, both surfaces replaced all of
#root, which is why the Dashboard had no sidebar and the workbench was destroyed on every switch. ADR-0003 documented that dispose-and-remount model deliberately, so it carries an addendum here.The shape
Exactly one host is exposed; the hidden one keeps its DOM and its state and contributes no layout.
src/application/main-surface.ts(new, pure)MainSurfaceState,resolveOpenDashboard(ok / missing / duplicate),reconcileMainSurface, route mappingsrc/ui/app-shell.ts(new)showHost()src/ui/app.tsopenDashboard(request),showQuerySurface(),openSavedQuery(), shell lifecyclesrc/ui/dashboard.tsBehaviour
openDashboard({ dashboardId, mode, focus })is the one entry point. It resolves by exact id; a missing or duplicate id reports through the shared diagnostic path and changes nothing. Opening never mutates the Dashboard.StoredWorkspaceV3gains noactiveDashboardId), cleared on sign-out, re-validated against every committed workspace. A deleted or ambiguous selection falls back to Query mode, never silently to another Dashboard. It is also the single writer of the route'ssurface/mode.[Back to query] · title · [View | Edit]. The style, refresh, search and filter controls stay where they were.Fixes this surfaced
workbench.destroy(), which aborts the in-flight request and issuesKILL QUERY— a presentation change must not cancel the query in the editor.onAuthLostnow dispose the Dashboard and advance the renderer generation, so its refresh/style shortcuts can't stay dispatchable from the login screen; and a write resolving after sign-out can't re-mount the signed-in shell over Login.dashboard-import-target-stale) instead of falling back to overwritingdashboards[0]— which would have destroyed a Dashboard the import never named. A test had encoded that fallback as expected; it is replaced by two that assert nothing is committed and no stored Dashboard is touched.main. Keeping the selection because the incoming workspace happened to carry the same id would silently open an unrelated Dashboard, and the next edit would commit to the wrong resource.Deliberately deferred
The favourite star still drives panel membership through #424's temporary favourite↔membership coupling, which writes the workspace's first Dashboard. Rewiring that is #427's job, so the star is gated: it declines with an explanation while a different Dashboard is open, rather than adding a tile to one the user isn't looking at. Tracked in #434 to retire with the coupling. Also filed: #435 (raw NUL bytes make
filter-bar.test.tsread as binary torg).Verified
npm test5199 passing with the per-file gate satisfied ·tsc --noEmitclean ·npm run buildclean · Playwright 300 passing across chromium/firefox/webkit.Because happy-dom applies no CSS, the layout was verified in real Chromium against a local ClickHouse 26.6: the sidebar stays 248px beside a 1145px Dashboard, one header,
.dash-pagenested in.dashboard-hostwithoverflow-y: autoand a sticky topbar, no page overflow; the editor's text and the 35% editor/results split survive a round trip; the grid measures 1145px (not 0 — the host is exposed before rendering, which happy-dom cannot check); and at 375px the Dashboard is full-bleed with the sidebar and bottom nav hidden. A long Dashboard title initially pushed View/Edit off screen at 375px — fixed and re-measured.Checklist
npm testpasses (the per-file coverage gate is non-negotiable)npm run buildsucceeds (single-filedist/sql.html)src/core/, network insrc/net/(injected fetch), DOM insrc/ui/CHANGELOG.md([Unreleased]) updated if behavior or the deployed surface changed🤖 Generated with Claude Code
https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1