Skip to content

feat(#425): Dashboard as a full-size main work surface, selected by stable id - #436

Merged
BorisTyshkevich merged 11 commits into
mainfrom
feat/dashboard-surface-425
Jul 25, 2026
Merged

feat(#425): Dashboard as a full-size main work surface, selected by stable id#436
BorisTyshkevich merged 11 commits into
mainfrom
feat/dashboard-surface-425

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

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

#root
  header slot            each surface installs its own
  .main-row[data-surface]
    .sidebar             built ONCE, visible on both surfaces
    .query-host          the SQL editor + result drawer — stays mounted, hidden in dashboard mode
    .dashboard-host      the Dashboard

Exactly one host is exposed; the hidden one keeps its DOM and its state and contributes no layout.

Layer What it owns
src/application/main-surface.ts (new, pure) MainSurfaceState, resolveOpenDashboard (ok / missing / duplicate), reconcileMainSurface, route mapping
src/ui/app-shell.ts (new) the persistent frame: header slot, sidebar, mobile nav, the two hosts, showHost()
src/ui/app.ts openDashboard(request), showQuerySurface(), openSavedQuery(), shell lifecycle
src/ui/dashboard.ts renders into the given host, selects by id, the new toolbar, tile/filter focus

Behaviour

  • 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.
  • Selection is session state — never persisted (StoredWorkspaceV3 gains no activeDashboardId), 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's surface/mode.
  • URLs are unchanged. The id stays out of the URL, which is why Back/Forward inside the Dashboard surface preserves the explicit selection instead of re-deriving one.
  • New toolbar: [Back to query] · title · [View | Edit]. The style, refresh, search and filter controls stay where they were.
  • Focus lands on one panel tile (by tile id, never query id) or one curated filter (by filter id), scrolled into view with a bounded highlight, delivered at the point each node actually exists — no timeouts.

Fixes this surfaced

  • A surface switch no longer calls workbench.destroy(), which aborts the in-flight request and issues KILL QUERY — a presentation change must not cancel the query in the editor.
  • A same-workspace Back navigation no longer runs the blanket control-disable, which would have permanently inerted the still-mounted editor toolbar, tabs and sidebar inputs.
  • Sign-out and onAuthLost now 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.
  • A commit that drops the selected Dashboard now renders the fallback rather than only recording it — otherwise every route back early-returned while the deleted Dashboard stayed on screen.
  • Export Dashboard and Import Dashboard address the selected entry instead of the collection's first.
  • An explicit import target fails closed. If the Dashboard an import targets was deleted concurrently, or its id became ambiguous, the import commits nothing (dashboard-import-target-stale) instead of falling back to overwriting dashboards[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.
  • A workspace switch always clears the selection. Dashboard ids are unique within a workspace, not globally, so two workspaces can each hold a 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.ts read as binary to rg).

Verified

npm test 5199 passing with the per-file gate satisfied · tsc --noEmit clean · npm run build clean · 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-page nested in .dashboard-host with overflow-y: auto and 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 test passes (the per-file coverage gate is non-negotiable)
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency (or it's a deliberate, justified addition — see CONTRIBUTING)
  • README / CHANGELOG.md ([Unreleased]) updated if behavior or the deployed surface changed
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68, the issue body, ADR/CHANGELOG) if this change reshaped it

🤖 Generated with Claude Code

https://claude.ai/code/session_01H8gEoANWfj1ckW1LpFypx1

BorisTyshkevich and others added 10 commits July 25, 2026 11:33
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
@BorisTyshkevich BorisTyshkevich mentioned this pull request Jul 25, 2026
93 tasks
…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
@BorisTyshkevich
BorisTyshkevich merged commit 00ea72e into main Jul 25, 2026
7 checks passed
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.
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>
@BorisTyshkevich
BorisTyshkevich deleted the feat/dashboard-surface-425 branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add dashboard selection state and a full-size dashboard work surface

1 participant