All notable changes to this project are documented here. The format follows Keep a Changelog, and the project aims to adhere to Semantic Versioning.
GitHub Releases (cut from v* tags by .github/workflows/release.yml) carry the
auto-generated per-PR notes; this file is the curated, human-readable history.
- A navigation section registry behind the left sidebar (#487, phase 2 of 4).
Each of the four navigation sections — Databases, Dashboards, Library, History —
is now addressable through one registry (
src/ui/nav-sections.ts) that owns its label, its icon, its accessible label and its single persistent host element, and both sidebar panes are composed out of those hosts. The lower pane's Library and History sections gained separate persistent search/list element pairs; before this they shared one pair that a tab switch repainted, which meant neither section's live DOM could be handed to another container without taking the other's content along. #426 established the pattern for the upper pane's two sections; this generalizes it to all four, so the hosts all carry one.nav-section-hostclass (it was.upper-role-host) and onedata-sectionattribute. The'library' ↔ 'saved'vocabulary bridge —AppState.sidePanelstill persists'saved'for the section #427 relabelled "Library" — now lives in exactly one place instead of being re-derived per caller, and both wide switchers take their label and icon from the registry so the two presentations of a section cannot drift. No user-visible change: the same two panes, the same switchers, the same splitters, and a section switch still clears the search exactly as before. What it buys is structural: phase 3's compact rail and docked focused drawer can move a section's live DOM instead of rebuilding it, which is what makes "wide and focused presentations share and preserve all navigation state" true by construction rather than by save/restore logic. - The desktop left navigation's layout core and preferences (#487, phase 1 of
4). A new pure
src/core/left-nav-layout.tsowns every layout decision the foldable left navigation needs: the named constants and thresholds, the explicit'wide' | 'rail'mode, the hysteresis reducer that maps a proposed width onto the next mode, the keyboard separator resolver, rail activation, the separator's ARIA range, and the mobile projection that makes a phone ignore rail mode without discarding the preference. Two new browser preferences back it (asb:leftNavMode,asb:leftNavDrawerPx); the wide sidebar's width deliberately stays on the existingasb:sidebarPxrather than gaining a second owner, and the focused section is session state that does not reopen after a reload. Hysteresis comes from the threshold pair — folding needs a proposal below 140px and restoring one above 260px — so no single pointer pixel can oscillate the mode, and the keyboard path routes through the same reducer as the pointer path rather than reimplementing it. Every function takes the navigation's proposed total width, which is what keeps a drag continuous across a mode change: handing the reducer a mode-relative width instead made a monotone rightward drag snap the navigation 108px backwards on the frame a drawer converted to the sidebar. Arrow keys resize within a band and perform the band edge's semantic transition, because an arrow's relative step cannot cross a dead zone wider than itself — leaving that relative stranded a bare rail'sArrowRightand a floored sidebar'sArrowLeftpermanently, whilearia-valuemin: 48was advertised throughout. Opening a section is a separate operation from the click toggle, since #428's bounded drag-hover re-asserts intent repeatedly and a toggle would flap the drawer shut. Every reducer normalizes its input, so the "a focused drawer exists only in rail mode" invariant is an unconditional postcondition rather than a precondition the caller has to honour. No user-visible change yet: the rail, the docked focused drawer and the resize separator arrive in phase 3.
- The sidebar's
'col'drag axis now clamps through the sameLEFT_PANEL_MIN_PX/LEFT_PANEL_MAX_PXconstants as the load path, instead of repeating180/420as literals (#487). Behaviour is unchanged; it removes the second owner of a range whose whole point is having one. VariableBarApp's shared activation port is now caller-neutral (#478).state.filterActive/params.saveFilterActive— named after Workbench persistence even though Dashboard's own caller uses them for an unpersisted in-memory draft — are renamed tostate.activeByName/params.saveActiveon the sharedvariable-bar.tscontract only. Every persisted name and storage key is unchanged (AppState.filterActive,WorkbenchParameterSession.saveFilterActive,effectiveFilterActive,asb:filterActive; no storage migration). Both callers now construct an explicit adapter instead of a same-shape cast: Dashboard aliases its local draft activation map with a no-opsaveActive(the draft is never persisted); detached Data aliases the realAppState.filterActiveobject and routessaveActivetoapp.params.saveFilterActive(). Pure rename + adapter refactor — no user-visible behavior changes.
- A corrupt
asb:sidePaneldecodes to the Library instead of propagating (#487). The lower pane's active section was read from localStorage undecoded, and every reader compared=== 'saved', so an unrecognized or obsolete value fell through to the History branch — neither the documented default nor the value's own meaning. It is now decoded once at load, like theleftNavModeand width preferences beside it. This became load-bearing rather than cosmetic in phase 2: with the pane's two sections on separate hosts, two readers resolving one value differently expose one section's host while painting into the other's, which renders as a blank pane. - A corrupt
asb:sidebarPxdecodes to the default width instead ofNaN(#487). The width decoded through a bareclamp(parseInt(stored), 180, 420), andclampis not NaN-safe (Math.max(180, NaN)isNaN), so a non-numeric stored value would reach the DOM aswidth: NaNpx— which the browser drops, collapsing the sidebar with nothing to explain why, and the bad value would persist across reloads. It now falls back to the documented 248px default, and decoding requires the whole stored string to be a finite number —parseIntaccepted a numeric prefix, so a truncated write or a hand-edited"200px"decoded to a plausible-looking width while the contract promised the default. Hardening rather than a reproducible user-visible bug: no code path in the app writes a malformed value (a real drag always carries a finiteclientX), so reaching it takes a hand-edited or foreign-originlocalStorageentry. The same NaN hole ineditorPct/sideSplitPct/cellDrawerPx/docPanePxis tracked separately in #570. - The Dashboard tree no longer reveals two rows' pencil/trash actions at
once, and its
· Ncount now sits inline after the label (#568). The hover/focus reveal rule (.dash-tree-row:focus-within .dash-tree-act) matched on any focus, so a row a user had merely clicked to select kept its actions visible indefinitely, alongside whichever other row the pointer was hovering — swapped to:focus-visible/:has(:focus-visible)(the row's own composite tab stop and its controls, respectively —:has()'s implicit descendant selector alone misses the row itself), which only a real keyboard Tab/Arrow grants, preserving reveal-on-keyboard-focus without pinning a clicked row open. A directly-focused action button (.dash-tree- act:focus, no:focus-visible) reveals only itself regardless of modality, so a programmatic focus-return or a test's direct.focus()— the pre-existing contracttests/e2e/tile-open-workbench.spec.js's "Panels-row plus" test already relied on — still works. Separately,Sales revenue · 2read as a right-aligned column rather than immediately after the name (Library · 66's placement, asdashboard-tree-model.tsalready documented as the intent): the label and count are now wrapped in one flex group so only that group grows, matching the narrow-sidebar test's own claim. - A detached Data pane's variable dropdown no longer shows a frozen
snapshot of recent values (#478 follow-up). #555's caller-neutral
VariableBarAppadapter copiedvarRecentintostateas a plain data property, captured once at adapter-construction time — but unlikeactiveByName(aliased, mutated in place),varRecentis replaced wholesale on every record/clear (workbench-parameter-session.ts), so a copy went stale immediately: a detached pane's "Clear recent" persisted but the still-open dropdown kept listing the cleared values, and a re-run's newly recorded value never appeared until the pane was closed and reopened. The Dashboard adapter (dashboard.ts) had the identical staleness pre-dating #555.VariableBarAppnow exposesgetVarRecent(): RecentMap, a live-read callback both adapters implement by reading their ownvarRecentreference at call time, instead of astate.varRecentsnapshot. - A Dashboard tile whose id is
__proto__orconstructorno longer loses its placement (#551, found reviewing #549). Both are legal tile ids underdashboardTileV1.id(pattern\S), and a placement-map write of the shapemap[tileId] = placementsilently drops them: the assignment invokes the inheritedObject.prototype.__proto__setter instead of creating an own property, so the tile falls back to the layout engine's default size. Worse, one read site (grafana-grid@2'ssetStylePlacement, merging a new style onto a tile's existing style map) read the same key back before writing it, and for a tile id with no own entry yet that bare read resolves toObject.prototypeitself — the subsequent merge-write mutated the realObject.prototypefor the whole process, not just the one layout. Every placement-map write (grafana-grid-layout.ts,flow-layout.ts,dashboard-document.ts's legacy-layout migrations and fallback regeneration,dashboard-commands.ts'sduplicate-tileand the flow→grafana-gridchange-layoutconversion) now goes through one sharedObject.defineProperty-based helper (defineJsonField, already used bycore/saved-query.tsfor the same class of Spec/panel JSON fields), and every read that could be merged or mutated goes through its paired own-property-only counterpart (readJsonField), closing thatObject.prototypeescape. - Dashboard styles now persist independent dimensions and temporary column
previews no longer mutate authored layouts (behavioral correction to
#535/#538). The new
grafana-grid@2contract stores Grid{span,height}, Full{height}, and Report{height}independently. Grid defaults to 6×2, Full to fixed 12×2, and Report to a centered 9×5; Full and Report resize only vertically and expose no Widen action. Grid Widen now changes width only, preserving height throughspan × 2up to 12 and then wrapping to 1. 2 columns and 3 columns are fresh session previews: every tile starts at span 1 and exactly 300px high, saved dimensions are ignored, Widen changes session state only, switching/rebuilding/reloading resets them, and mobile collapses them to one column. Existinggrafana-grid@1andflow@1documents normalize deterministically at codec boundaries without changing the Dashboard or workspace document version. A Dashboard whose primary layout engine this build cannot load — an unknown engine, or a futuregrafana-gridversion — is still READ through itsflow@1fallback, but selecting an authored style now fails with a diagnostic instead of stamping the requested preset onto a layout it cannot convert. A Dashboard that opens directly in Report exposes its vertical-only resize label from the first render. Downgrade limitation: a build predatinggrafana-grid@2also reads such a Dashboard through that fallback, but must not be used to EDIT one — it rewrites the fallback from placements it cannot interpret, so layout edits made there are lost (authored Grid/Full/Report dimensions are never touched, and this build regenerates the fallback deterministically on the next read). Gating editing on fallback use is tracked in #550. build/check-boundaries.mjsnow enforces thatsrc/corestays a pure, reusable-logic leaf (#455): a newRULESentry rejects any import fromsrc/core/**intosrc/workspace/**,src/application/**,src/ui/**, orsrc/net/**, matching the invariantsrc/application/main-surface.tsalready documented but that nothing mechanically enforced. Deliberately narrower than "no imports from any other layer":src/editorstays reachable, preserving the documented legacy type-only import fromcore/saved-io.tstoeditor/spec-editor.types.js.- The typography contract now catches
openMenu/openConfirmMenuclasses supplied through options rather than anh()class:literal (#498): a newopenMenu extension classes have CSS rulestest scans all ofsrc/ui/**/*.tsfor literalmenuClass/extraClassvalues (handling several whitespace-separated classes per value) and, separately, thegoClass/cancelClassvaluesopenConfirmMenucallers pass at theirdash-tree-confirm*/qtab-close-confirm*/dash-tile-confirm*call sites (confirm-menu.ts forwards these asextraClass, so the literal lives at the caller, not the shared helper). A sabotage test proves the contract actually fails when a matching CSS rule is removed. The scan surfaced one real, previously uncaught gap:dash-style-item(the Dashboard layout-picker row,dashboard.ts) had no CSS rule at all;src/styles.cssnow gives it one. tests/unit/filter-bar.test.ts's 4 raw NUL bytes were already resolved (#435) — the file was renamed totests/unit/variable-bar.test.tsby the #459 filter→variable rename, which replaced the four separate literal NUL bytes with oneGROUP_KEYconstant built from the JavaScript escape sequence for code point zero, matching the escapesrc/core/time-range.tsuses in production. Verified no raw NUL bytes remain anywhere undersrc/ortests/; no code change was needed.
- Sidebar tab headers go text-only once the sidebar is dragged to 220px or
narrower (#552) — both the upper Databases/Dashboards role switcher and
the lower Library/History switcher hide their icons and
· Ncounts at that width, keeping the four labels readable, aligned and clickable with no clipping or overlap even at the sidebar's 180px drag minimum. A pure CSS container-query gate (@container sidebar (max-width: 220px), named/scoped on.sidebar's newcontainer-type: inline-size) restores the full presentation the instant the sidebar widens past the threshold, with no JS state to reconcile. - Dashboard tree rows now use one consistent column layout for counts and
actions, and Add panel moved to the Panels row (#553). The Dashboard row's
panel count now renders as the same inline
· Nright after the label that Variables/Panels already used, instead of competing right-aligned text next to the action cluster; the rightmost column is actions only. The Add panel (+) control moved from the Dashboard row to the Panels group row, since it creates a member of that group. #552's narrow-sidebar container query (@container sidebar (max-width: 220px), reused verbatim — no second breakpoint) now also hides the Dashboard tree's· Ncounts, recovering space for the Dashboard/Variables/Panels labels; the count stays in each row's accessible name even while visually hidden, and every row's expand/collapse and direct actions remain reachable by mouse and keyboard.
- The Caddy container now runs under Kubernetes' no-new-privileges policy. Its unneeded privileged-port capability is removed during the image build; the smoke test exercises the same policy.
- Dashboard tile descriptions now match the selected presentation density. Full view and Report show the saved-query description beneath the tile name; Grid Tiles and the 2/3-column styles keep only the name visible and expose the description by hovering that name.
- Deleting a panel from its tile header no longer leaves its query behind in
Library (#537). The tile-header trash dispatched a document-only tile
removal, which leaves
queriesuntouched — and since every panel tile is the sole owner of a dedicated saved-query copy (#427), that left the copy with zero owners, which is precisely what makes a query a Library query. A deleted panel therefore reappeared as an apparently standalone Library entry. The tile header now uses the same confirmed, ownership-proven, atomic path the Dashboards tree does: the tile and its dedicated query go in one commit, and the write refuses outright — changing nothing, with a diagnostic — for a query that is missing, shared, ambiguous, retargeted, or not a panel query. There is no implicit "move this panel query into Library" behaviour. - WebKit now follows Dashboard-tree keyboard focus through concealed row actions (#540). Explicit, roving tab stops keep Tab on the focused row's chevron and actions; a pointer- or programmatically-focused row becomes the owner too, so returning from an action dialog continues through that row rather than skipping its remaining controls.
- OAuth document-recovery E2E runs no longer reuse an incompatible stale harness server (#533). Playwright now probes the fixture's server-only config route before accepting an existing process, preventing misleading ready-latch timeouts when an older static harness still owns the test port. The fixture also drains every intercepted startup ClickHouse request before arming its deliberate 401, so a late catalog success cannot mask auth loss.
- Dashboard variable option SQL can no longer pass Run with more than 1000
rows (#496). The existing bounded probe still fetches one sentinel row, but
Run now rejects that 1001-row response after column-shape validation and
before History or detached-result source capture. Zero through 1000 raw rows
remain valid, authored
LIMITtext is not parsed, and transport errors or cancellation keep their original outcome. - Workbench executions now claim one shared run slot before awaiting authentication (#503). Ordinary queries, scripts, and Dashboard-variable probes can no longer race through a pending config load or token refresh; cancellation during preflight prevents a late request, and operation-local timers, query IDs, and controllers keep stale finalisers from disrupting a newer run.
- In-place authentication recovery no longer compresses the mounted workspace (#512). The preserved editor or Dashboard now stays behind a blocking viewport overlay, rather than being pushed below a second, independently scrolling login region.
- The Docker SPA runtime now uses Caddy with build-time compression sidecars.
The image carries Brotli (quality 11), Zstandard (level 19), and gzip (level
9) variants of
sql.html; Caddy negotiates them fromAccept-EncodingwithVary: Accept-Encodingand never compresses requests at runtime. The ClickHouse and release-bundle paths continue to distribute the single rawsql.htmlartifact. - A Dashboard panel tile head now carries two controls and a
⋯menu (#544). #535 had left the head with five — grip, duplicate, widen, expand, delete — competing with the tile title for one flex row, and a tile a single grid column wide has no row left to compete for. Edit mode is now grip · title · widen · ⋯; View mode is unchanged, keeping its single direct expand icon, because one action does not need a menu. The⋯always lists all four actions — Duplicate, Widen, Open in Workbench and run, and Remove tile — and each row states why it cannot run when it cannot, so nothing is merely absent. The inline widen is a shortcut on top of its row: a CSS container query withdraws it below roughly 260px of tile, where the ellipsized title has nothing left to give, and the row remains. Remove now works under every layout style: it was gated twice to Grid Tiles — a CSS ancestor scope and an engine check inside its own click handler — so Report, 2 columns and 3 columns had no delete at all, and a flow KPI band member had none under any style. This reintroduces an overflow menu on a surface #494 removed one from; that decision was about a two-control row in a fixed-width side pane, and is not reopened here.
- Dashboard
Boolvariables now use compact tri-state checkboxes (#530). Checked and unchecked emit the same canonicaltrueandfalsevalues as the former choices, while the existing Clear all action restores the native indeterminate unset state. The shared Dashboard variable bar uses the control in both Dashboard variable surfaces; non-Boolean variables keep their current controls. - Same-named Workbench tabs now show their source only when disambiguation is needed (#464). Colliding Dashboard tabs receive a compact Dashboard icon and the shortest readable title abbreviation; Library and draft collisions receive explicit labels, while unique tabs keep the existing compact layout. Every tab exposes its full source through a semantic tab control and tooltip, including Dashboard-variable tabs. Roving Arrow/Home/End navigation preserves focus through activation and close-driven strip replacement, and accessible names announce unsaved, externally changed, and externally deleted state. Source presentation is derived from the canonical saved-query/Dashboard ownership graph rather than stored as a second identity, and repaints immediately after Dashboard renames. Dirty, conflict, deleted, and close controls retain their established order and behavior.
- A Dashboard panel tile head carries duplicate, widen and expand actions
(#535, revised by #544 above — all but widen moved into the
⋯menu in the same release, and widen kept an inline button alongside its row). Duplicate (edit mode) places a copy of the panel immediately after the source, carrying its presentation, any local title/description and its size; because every panel tile solely owns a saved-query copy (#427), the copy gets its own dedicated clone rather than sharing the source's document. Widen (edit mode) steps a tile through the widths the active style has. The #538 behavioral correction above makes 2/3 columns session-only previews and makes Grid widening horizontal-only: doubled span up to 12, then 1, with height preserved. Its label names the width the next press produces, and it hides itself under Report, under Full view and below the mobile breakpoint, where there is no width to step. Expand — the existing Open in Workbench action, now on the design's expand glyph — additionally runs the opened query on its own saved view and reveals the panel's Dashboards-tree row, so leaving a Dashboard no longer loses your place in it or lands on an empty result pane. A flow KPI band member gets duplicate and expand but not widen: a band ignores tile widths. Duplicate and expand carry the design's own glyphs; widen ships as a horizontal double-headed arrow rather than the design's four-way arrows, which are drawn at 24px and become an unreadable blob — and near-indistinguishable from the expand brackets beside them — at the 13px a tile head renders. This restores in-tile width authoring, which #280 phase 8 had moved into the Spec editor; the free-form two-dimensional resize remains the corner drag. - OAuth reauthentication now preserves dirty document work across its page
redirect (#512 phase 3). Before navigation, a versioned, expiring
sessionStoragecheckpoint captures authored tab state only and binds it to the OAuth attempt plus the current workspace. A successful callback loads the committed workspace, reconciles linked tabs, restores and revalidates raw Spec drafts before first render, reinstalls the dirty unload guard, and only then consumes the checkpoint. Results, ClickHouse session ids, credentials, and other execution state are never checkpointed. Failed callbacks retain the drafts for a state-rebound retry; malformed, expired, or workspace-mismatched payloads fail closed without deletion until their matching workspace returns. The separate tab-scoped, 15-minute validated-callback marker binds state and validation time to a canonical live-document fingerprint; a checkpoint alone is never retry authority, and expiry is logical eligibility rather than a promise of eager deletion. Unavailable workspaces and prepublication storage/validation failures retain unpublished recovery. Valid pending recovery suppresses legacy shared content and retries after authoritative workspace load without overwriting a newer document session—even after that work is saved clean. Changed sessions require an explicit Restore drafts action; the marker is retired before publication. Callback mismatch does not revoke unrelated pending authority. A new OAuth attempt invalidates older authority. Redirect preparation is transactional across checkpoint, marker, IdP, origin, verifier, state, and return route; the intentional redirect receives a one-shot unload bypass only after storage succeeds, and explicit Log out clears the checkpoint and marker. - Temporary ClickHouse authentication loss now suspends only authenticated
execution, not the in-memory document session (#512 phase 2, absorbing
#502/#520/#522). A disposable, epoch-fenced execution scope coordinates
Workbench and Dashboard queries, exports, schema graphs, catalog/reference
loads, formatting, SHOW CREATE, and detached-result refreshes. Closing it
aborts local work before best-effort server cancellation with an immutable
origin and
Authorizationlease, and stale completions cannot publish into the next authenticated epoch; stale preflight work is also rejected before it can send a replacement epoch's credentials. The mounted shell, exact editor/tab/result objects, drafts, dirty state, route, and unload guard survive while the header turns red and the existing authentication controls appear inline. Successful Basic reauthentication installs a fresh scope and reloads connection metadata in place; its reusable inline form clears the password, resets submission state, and hides password visibility after success, ready for a later recovery. Inline OAuth uses Phase 3's durable document checkpoint; explicit Log out remains destructive. Async error-body classification and config discovery are epoch-fenced; refresh authority snapshots its epoch and rechecks it after config discovery before token-endpoint I/O, so stale discovery cannot replace a newer auth-header policy. Catalog, schema, reference, and docs transports share a connection-generation abort signal:invalidate()aborts them synchronously, while stale writes remain fenced. - Connection state now has one explicit, epoch-fenced lifecycle (#512
phase 1). OAuth/Basic credentials, single-flight token refresh, transport
success/failure, auth loss, reauthentication, and explicit sign-out flow
through
ConnectionSession; stale refreshes cannot publish tokens or lifecycle state into a newer login. The accessible header chip reacts to that lifecycle (green connected, amber offline, red auth-required) instead of inferring connectivity from the best-effort server-version probe. HTTP query failures remain query errors and do not falsely mark the transport offline. - A Dashboard row can create a blank Panel directly from the Dashboards tree (#515). Its new plus action opens the shared name/description dialog, then atomically commits one empty panel-role query and one canonically laid out tile—without selecting or copying a Library query. The action is keyboard-accessible, unavailable for ambiguous or 100-panel Dashboards, and a successful Add reveals the new Panel and opens its clean linked tab with focus in the SQL editor.
- Every Library query now has a keyboard-accessible “Add to dashboard…” action (#483). Its Dashboard → panel chooser uses the same assignment command as drag/drop, creates the same independent owned query and tile, and supports arrow navigation, Escape, Cancel, and focus restoration. The compact Plus appears on row hover or keyboard focus; a successful add switches the sidebar to the Dashboard tree and selects the new panel. Both chooser stages flip above a row near the viewport bottom, and oversized menus scroll within the viewport instead of placing Add/Cancel off-screen.
- The unused saved-query repair planner has been removed (#429 phase 6 / #500). Direct, ownership-safe Panel and Dashboard trash actions are the supported cascading delete paths; Library deletion and saved-query edits continue to fail closed when whole-workspace validation would invalidate a live Dashboard member.
- Every tagged release now mirrors
sql.htmltodocs.altinity.com/altinity-sql-browser/sql.html(docs/sql.html, published by a newrelease.ymlstep, alongside a demodocs/config.jsonoffering the Antalya/github.demo clusters) — a try-it-now link independent of any ClickHouse-served deployment. FixedloadConfigDoc(src/net/oauth-config.ts) to resolveconfig.jsonfrom the containing directory whenbasePathis a static filename (e.g.sql.html) rather than a route (e.g./sql), which this static hosting case needs and the ClickHouse route did not exercise. - File ▾ → Import example dashboard… (#506): a modal dialog lists the three
flagship example dashboards (ClickHouse Operations, Shop Charts, OnTime
Charts) by their catalogue name; Import is disabled until one is selected,
and Cancel/Escape/close leave the workspace untouched. Selecting one runs it
through the exact same portable-bundle decode/validate/dependency-closure/
commit path a file-based Import dashboard… uses — purely additive, so an
existing Dashboard is never replaced or merged into. The catalogue is
examples/dashboard-manifest.json(the ordered{file, name}source of truth) plus the three referencedexamples/*.jsonfiles, compiled by the newbuild/compile-example-dashboards.mjsinto the checked-insrc/generated/example-dashboards.ts(mirroring the JSON-Schema generation pipeline) so the catalogue ships inside the single build artifact;npm run check:examples(wired intoprebuild/pretest) fails the build on a missing, malformed, or stale example so one cannot ship unnoticed.
shop-charts.jsonandontime-charts.jsonmoved to portable-bundle v2 / Dashboard document v2, dropping their curatedfiltersarrays in favor of inferred Variables — the phase-3 example rewritespec-examples.test.jshad been pinning them against, followingclickhouse-operations.json's earlier move (#458 follow-up). Everyfrom/to/country/category/carrier/originvariable was already targeting exactly the tiles whose query declares its{name:Type}placeholder, so the curatedtargetslists these examples carried added nothing the placeholders didn't already say; removing them changes no runtime behavior.build-ontime-charts.mjsnow passes its bundle's ownversionthrough towriteExampleBundleinstead of silently reverting to v1 on its next regeneration.- The three flagship dashboards' Array/enum variables get real option lists
again.
country/category(shop-charts.json),carrier/origin(ontime-charts.json), anduser/query_kind/exception_code/query_hash/metric/is_initial_query(clickhouse-operations.json) had been reduced to bare freeform-text Array inputs ever since #447's mechanical minimum pass dropped every curated filter'ssourceQueryId— the pre-#447 option-source queries existed (shop-filter-options,ontime-filter-options,gco-filter) but returned one row of ARRAY-typed columns, a shape the v2 option-SQL contract (one row per option, two String columns: value, then label) cannot reuse directly. Each is now ported into its ownvariableConfigs[name].sql, so these variables render a searchable (multi-)select again instead of pure freeform text, restoring the pre-#447 browsing experience under the new inferred model.
- A fresh time-range pair seeds to an active "-1d" → "now" range instead of
empty. Most panel queries declare their
from/to(orstart/end,from_time/to_time,start_time/end_time) variables directly in a requiredWHEREclause, never behind an optional/*[ … ]*/block — so on first load, before #447's curateddefaultValuewas removed, every one of those panels was blocked until the user set a range by hand. A never-persisted time-range bound (no committed value in this browser for this Dashboard yet) now starts at a real, running-1d→now, active from the first render; a variable outside every resolvable pair, or one with ANY already-persisted value (even a deliberately cleared one), is untouched. - An error toast now stays up long enough to read.
flashToastdefaulted every toast — success or failure — to the same 1.6s auto-dismiss, so a failed open/import/save (✕ …, the app's established failure marker) could vanish before it was even fully read, let alone copied for a bug report. An error toast's default now runs 30s instead; a plain informational toast (no leading✕) is unchanged. An explicitdurationpassed by the caller still wins either way.
-
Dashboard and Panel rows act directly, and the
⋯overflow menus are gone (#494 / #429 phase 4). A Dashboard row now carries a pencil and a trash; a Panel row carries a pencil that edits its dedicated query's name and description, and a trash that removes the tile and that query in one atomic commit. Both clusters follow the Library Query row's vocabulary: revealed on hover and:focus-within, destructive action rightmost, each button separately named for a screen reader (Edit Revenue,Remove Revenue from dashboard), each keyboard-operable inside the row's single tab stop, and each isolated from the row's own click/Shift-click/expand gestures.Deleting anything confirms first, naming both the panel and the Dashboard and saying that the dedicated query copy goes too — with keyboard focus on Cancel, through the same
autofocusrow #501 added for the orphaned-variable confirm. A panel delete removes the tile, every layout placement (including the grafana-grid flow fallback) and exactly its owned query, bumps that Dashboard's revision once, and moves keyboard focus to the next panel row — then the previous, then the Panels group. An orphaned variable configuration deliberately survives.Edit and delete are only offered when ownership can be proven: the tile's query must exist and be owned by that one tile. A query shared between panels, or a reference to a query the workspace no longer carries, leaves both controls rendered but
aria-disabledwith the reason in their tooltip — nothing is ever guessed or cascade-deleted. Every dialog and confirmation re-resolves its target inside the write queue, so one that went stale while it was open commits nothing and says so.Every one of those operations re-proves the exact identity the user confirmed, inside the write queue: the Dashboard, the tile, the query the confirmation named, that the tile still references it, that exactly one document carries that id, and that it is a panel-role query. A tile re-pointed while the confirmation was open, an ambiguous id, or a reference to a non-panel query all refuse and say why, rather than deleting whatever the tile happens to point at by then.
A fifth review pass found the Dashboard/tile identity check was looser than delete's own: both the tree's availability rule and the metadata guard asked "is there at least one match" rather than "is there exactly one", so two Dashboard documents sharing an id — or two tiles of the same Dashboard sharing an id, even when they reference different queries — could still offer a pencil that delete already refused. Both now count Dashboard and Dashboard-local tile ids the same pass counts query ids, and refuse identically. The pencil's own rapid-reactivation race is fixed too: replacing an already-open dialog force-closes it first, and that closing dialog's
onCloseused to reset the same trigger'saria-expandedback to"false"right after the new dialog had just set it"true"— stranding the trigger effectively unfocusable for as long as the replacement stayed open. And the viewer's tile-description override now trims before the fallback, matching its title override (#476): a whitespace-only description is schema-legal and was silently masking the query's own description with nothing, since the tree's own warning note already trimmed and so never fired for it.A sixth pass found the fifth round's own fix incomplete: counting duplicated ids made edit/delete agree with delete's stricter rule, but the row's presentation identity —
row.key, and thedata-keyevery focus restoration, drag highlight and the roving tabindex resolve by — still collapsed two ambiguous rows onto one key. A duplicated Dashboard or tile id could therefore put more than one row in the Tab order at once, and let focus/highlight resolution silently pick whichever matching DOM node came first. Malformed duplicates now get distinct presentation keys, and a Panel row's own View/Edit Dashboard-focus navigation (addressed by Dashboard id + tile id, unlike its still-available open-query gesture, addressed by query id alone) is withheld under the same ambiguity — the Dashboard viewer's own tile-focus lookup is keyed by tile id alone and would otherwise resolve the other duplicate. Every drop target this ambiguity could reach (the Dashboard row, its Panels group, and a Variable row) is withheld too.The same pass found the orphaned-variable trash still discarding its own commit outcome (
void commitVariableConfig(...)) — the one destructive control on this row that predates this round'sreportRemovalpattern for the Dashboard and Panel trash. A concurrent Dashboard deletion, a Dashboard that became a duplicate id, or a storage rejection all committed nothing and said nothing: the confirmation just closed. It now awaits and reports the outcome the same way, and the orphan-delete control is itself withheld when the Dashboard id is already known to be ambiguous.Not yet included: the Open in Dashboard focus button, which is held back until #438 fixes tile focus on flow-layout KPI tiles.
-
Closing a dirty tab, or leaving the page, now confirms first (#466). The tab strip's close button asks before discarding an unsaved draft — a normal query tab or a Dashboard-variable's option SQL, saved-linked or never saved anywhere — instead of dropping it unconditionally; Cancel keeps the tab open exactly as it was. Reloading or closing the browser tab/window while any tab still holds an unsaved draft now shows the browser's own native confirmation too. Both read the same dirty predicate the tab's own dirty-dot indicator already used, so nothing needed reclassifying — a never-saved scratch tab gets the same protection as a saved query. The confirm's default keyboard focus is Cancel, not the destructive action, so pressing Enter right after opening it never discards the draft — the Dashboard tree's own orphaned- variable delete confirm gets the same fix (#501), same root cause. The page-leave warning installs and removes itself as tabs go dirty/clean rather than staying permanently registered, since Firefox (and older Chromium) exclude a page from the back/forward cache merely for having a
beforeunloadlistener attached at all — including re-syncing immediately after a save that clears a draft's dirty flags while navigating away from its surface mid-write, rather than only on the next unrelated tab repaint. -
A Dashboard row's pencil edits its title and description (#429 phase 3). It opens a small dialog prefilled from the Dashboard's own committed document — Cancel and Escape commit nothing, a blank title disables Save, and a real commit bumps only that Dashboard's revision and refreshes the tree and any open Dashboard surface. (Phase 3 put this pencil beside the row's
⋯menu and shipped no Dashboard trash; #494 above then removed that menu and added the trash, so what ships in this release is the pencil as part of the direct-action cluster.) -
Drag a Library query onto a Dashboard to assign it (#428). One drag from the lower Library list now has three destination-dependent meanings, and the row publishes two independent payloads to serve them:
- onto a Dashboard row or its Panels group → an independent panel-owned copy of the query plus a tile referencing it. The Library entry is never modified, never removed, and keeps its favourite; repeated drops of the same source create genuinely independent panels rather than sharing one query. The new tile's placement is seeded from the source's own size hints, so a wide panel lands wide.
- onto an inferred Variables row → the query's SQL text is copied into that Dashboard variable's option-SQL configuration. Only text: no clone, no tile, no owner, no lineage. Later edits to the Library query do not reach the copy.
- onto the main SQL editor → unchanged from PR #40: the
( … )subquery insertion at the drop position, still undoable, still touching no storage.
Dashboard assignment never trusts what it reads off the drag. The payload is just
(workspaceId, queryId), re-resolved against the latest committed workspace inside the write queue — so a source deleted, edited, or turned Dashboard-owned mid-drag aborts cleanly, a drop landing after a workspace switch is rejected, and an ordinary same-workspace change simply rebases. Every assignment is one atomic, fully validated commit that bumps only the target Dashboard's revision, exactly once.Eligible destinations light up while you drag, the row under the pointer takes a dashed outline, and resting on a collapsed Dashboard opens it — along with both its groups, so Panels and Variables rows become reachable without dropping first. That auto-expansion is view state only: it never navigates and never writes. The Variables group is deliberately not a target (it does not say which variable), nor is an individual panel row or an orphaned variable.
A successful drop lands on what it created: the tree expands to the new panel or variable row and focuses it, and the assigned document opens in the editor — for a panel the new owned copy (not the Library original), for a variable its
Variable: <name>tab. The Dashboard itself is still never opened in View/Edit, and nothing is executed.A drop can never silently overwrite an unsaved variable draft. If the matching
Variable: <name>tab has unsaved changes the assignment is refused outright and that tab is focused, and the check runs inside the write transaction rather than before it, so a keystroke landing while the commit is queued cannot slip past. A keystroke landing while the commit is persisting is a window no check can close — the write is already durable — so that case is reported instead of hidden: a toast that stays until you act on it explains that the tab's unsaved changes now differ from what was assigned, and offers Discard draft to adopt the assigned SQL. Nothing discards your typing on its own. Dropping a blank query is refused as a no-op rather than treated as a deletion — clearing option SQL stays the variable tab's explicit blank-Save.The empty-Dashboard message now names this route. The keyboard-accessible Add to dashboard… equivalent is tracked separately in #483; until it lands, assignment is pointer-only.
-
The File menu creates, imports and exports Dashboards across the whole workspace (#463). Its three Dashboard commands still assumed one exact Dashboard was already open — a model that stopped fitting once a workspace could hold many. There was no way to create a Dashboard from the menu at all; Import Dashboard… was disabled on the Query surface with Open a dashboard and, where it did work, replaced whichever Dashboard was open; and Export Dashboard… was unavailable unless one was on screen, even with Dashboards sitting in the workspace to choose from.
All three are now workspace operations:
- New dashboard… — a new row, immediately after New workspace…. It asks for a name, appends an empty Dashboard to the current workspace, opens it in Edit mode, and switches the upper sidebar to the Dashboards tree so the new Dashboard is actually visible in the list that marks it selected. Duplicate names are allowed (identity is the id); Cancel and Escape commit nothing, and a rejected commit leaves navigation, the sidebar and local state untouched.
- Import dashboard… is additive and available from Query, Dashboard View, Dashboard Edit and the empty-Dashboard placeholder whenever a writable workspace exists. The imported Dashboard is appended under a freshly minted id and opened the same way a created one is, so it can never merge into or replace the one you are looking at — and the destructive Import and replace current Dashboard? confirm is gone with the replacement it gated. Dependent queries still go through the existing conflict planner.
- Export dashboard… is workspace-aware rather than surface-gated. Zero
Dashboards disables it with No dashboards; the Dashboard on screen (in
View as well as Edit) exports in one click; a sole Dashboard exports
directly from Query too; anything else opens a chooser listing each
Dashboard's tile count and an id fragment — widened until no two rows read
alike — so duplicate names stay distinguishable. Both choosers focus their
first row and keep Tab inside the dialog. Every export resolves an exact
id in the workspace it was chosen from — contents and file name both: ids
are unique within a workspace, not across them, so switching workspace while
the export flushes pending writes cancels it instead of exporting a same-id
Dashboard from the new one, and no export lands under the name of the
workspace you moved to. There is no fallback to
dashboards[0]left to reach.
The menu regroups by verb — create / import / export / download — behind three dividers, and the two Dashboard rows drop to sentence case. Row order, separators, labels, availability and reason text remain the pure model's alone (
core/file-menu-model.ts), and context still changes enabled state only: no row appears, disappears or moves when the work surface changes.
-
Enter on a Dashboard-tree action button ran the row's command instead of the button's (#495 review). The tree's keyboard handler lives on the list and its Enter arm runs the focused row's action, so Enter on the rename pencil opened the Dashboard — and could swallow the button's own activation on the way. Every nested control now stops Enter/Space from propagating (without preventing the default, so native activation still fires exactly once), and the tree handler independently ignores an Enter that originated on a button. Arrow keys, Home and End still walk the tree from a focused control.
-
A failed Dashboard rename closed its dialog and discarded the edit (#495 review). The dialog now waits for the write: a Dashboard deleted in another tab, a duplicate id, a validation rejection or a storage failure keeps the card open with the typed text intact and shows one targeted message inside it. Both buttons are disabled while a write is in flight, so the same rename cannot be submitted twice.
-
Creating a Dashboard from the empty-workspace placeholder said nothing when the commit was rejected (#495 review / #481). Both entry points — File ▸ New dashboard… and the placeholder — now run one creation command and report identically; each keeps its own "where to go afterwards" policy.
-
Modal dialogs were invisible to assistive technology (#495 review). The shared dialog shell now marks its card
role="dialog"witharia-modal="true"and anaria-labelledbypointing at its own heading, so a screen reader is told a modal opened and which one. -
A Dashboard variable's Run action validates its option SQL again (#465, follow-up to #457). #457 moved option SQL into a
dashboard-variablemain-editor tab and deleted the drawer's Test action along with it, but nothing replaced the check it performed — the ordinary Run action executed the SQL raw and reported a plain success even when it returned the wrong number or type of columns. The break surfaced later and further away: only when the Dashboard-wide option batch failed, as one mergedUNION ALLcolumn list that cannot say which variable's branch was at fault.Run on a
dashboard-variabletab is now validated as a variable option query end to end. Every locally-detectable problem — blank or comment-only SQL, a non-SELECT, more than one statement, aFORMATorINTO OUTFILEclause, a{name:Type}parameter, an optional/*[ … ]*/block, an unterminated string or comment, the reserved batch-tag column name — is reported with no request sent, before authentication. (A multi-statement query is rejected by name rather than silently handed to the ordinary script runner, which has no concept of this contract.) A query that passes runs through the same bounded, read-only single-branch probe (row cap,max_result_bytes,readonly: 2) the option batch's own branches run under, so Run can never accept SQL the batch would reject, nor pull an unbounded result of its own. Its response is then checked against the same shape the batch requires — exactly two non-nullableString-compatible columns (String,LowCardinality(String),FixedString(N)) — and a shape failure is never recorded as a successful run (no history entry, no detached-result source); a genuinely valid run keeps both, exactly as it did before this fix. Ordinary query-tab Run behaviour is unchanged.The Explain button/view-switch now say so instead of silently doing the wrong thing on this tab: EXPLAIN has no meaning for a two-column option-SQL contract, and forwarding it into the new validated Run path would have run an ordinary probe with no indication Explain was ignored.
-
Opening a saved query resolves it before it navigates (#443, #429). Handing
openSavedQueryan id that names nothing used to switch to the Query surface and push a history entry first, then discover the query was missing — opening no tab, showing no diagnostic, and leaving the user somewhere they never asked to be. It now resolves first: a miss reports "That query is no longer part of this workspace." and changes no surface, no route and no tab, while a resolving id behaves exactly as before. -
A whitespace-only tile title no longer blanks a heading or a screen-reader name (#476, #429).
tile.titlecarries nominLength, so a hand-authored or imported" "is a legal document — and being truthy it beat the query-name fallback, leaving the visible.dash-tile-nameempty and composing accessible names like "Open, — , in Workbench". The Dashboard viewer now trims the authored title before the fallback, so such a title behaves exactly like an absent one and shows the query name instead. A title with surrounding whitespace is kept, trimmed. This changes what existing documents carrying such a title display; no shipped UI writestile.title, so only hand-authored and imported documents are affected. -
A degraded workspace export no longer loses a Dashboard (#463). When the committed read is unavailable — blocked, over-quota or private-mode IndexedDB — the File menu rebuilds the workspace from its live projection. That projection has been the selected Dashboard since #425, but it was folded back through the compatibility slot, which is entry 0 unconditionally: with Dashboards
[A, B]and B on screen, the reconstruction produced[B, B], dropping A and minting a duplicate id. It now folds back by exact id, leaves the committed collection alone when the projection names no stored entry, and seats the projection only when there is genuinely nothing stored. This is the path a user reaches precisely when their storage is failing and they are trying to get their work out. -
An
Array(T)Dashboard variable with option SQL is a searchable multi-select (#468).Array(T)is the type multi-select exists for, but #447 phase 1 removed the curated model's multiselect control as an owner decision and phase 2 classified every container as having no inferred control — so a variable likeuser : Array(String), with a perfectly good option query configured, rendered a free-text box and never ran that query. The #189/PR-#364 control is back, now driven by the variable's own Dashboard-local option SQL.The closed trigger reads
Not set, the single option's label, orN selected. The popover offers a labelled search over both labels and values, a tri-state Select visible scoped to whatever the search currently shows (hidden rows are never touched), per-option checkboxes, and Clear / Cancel / Apply. Nothing commits until Apply, which canonicalizes by option order, fires at most once, and re-runs only the panels declaring that variable — a no-op Apply issues nothing. Cancel, Escape and clicking outside discard the draft and return focus to the trigger.Selections stay real arrays end to end — through viewer state, the per-Dashboard store, and the existing typed
Array(T)serializer, which bindsparam_user=['ada','bo']with quotes, backslashes, Unicode and big integers all escaped, never a joined"ada,bo". An empty selection is unset (the panels wait) rather than a literal[], which would return nothing while looking filtered. Selections survive a reload.A committed selection is never silently changed. While the option batch is in flight the control is inert (
Loading options…) — the Dashboard is mounted before the request finishes, so an Apply against a list that had not arrived would otherwise clear a restored selection. An automatic refresh only ever removes values that are genuinely gone, in one coalesced wave, and preserves the committed ORDER: the array binds as an ordered ClickHouse literal and panel SQL may read it positionally, so re-sorting it to match a new option order would change what panels bind without re-running them. A label-only or option-order-only change therefore does nothing at all. If the option list came back truncated at the 1,000 cap, nothing is pruned at either end — a selected value may simply live past the cap, so the refresh leaves it alone and Apply keeps it rather than dropping it for being absent from a list that is known to be a prefix. (Clear still removes everything, and with a complete list an absent value is genuinely gone and does get dropped.) New options are never auto-selected.Eligibility is one pure predicate (
multiSelectElementType) shared by the option batch and the control dispatch, so a type whose option SQL ran can never be one the bar refuses to render a select for.Tuple,Map,Nestedand nestedArray(Array(…))are unchanged: no option query, no select, and the same free-text field plus marker as before. AnArray(scalar T)with no option SQL also keeps that field, but its marker now names the fix ("add option SQL to pick from a list") instead of calling a controllable type uncontrollable.Fixes a latent bug: every
Array(T)variable with valid option SQL was silently markedstatus: 'error'by a branch commented "unreachable viaoptionBatchVariables' own rule" — which was wrong, and invisible only because the select it would have applied to never rendered. -
Dashboard variables can offer a list of values, and every list on a Dashboard loads in one request (#447, phase 2). A variable (inferred from the
{name:Type}placeholders in its panels' SQL) may carry optional Dashboard-local option SQL: one read query returning twoStringcolumns — value, then label, by position; column names are never read. A variable with option SQL renders a strict single-select; one without keeps the direct-input control inferred from its declared type.Every configured variable on the Dashboard is compiled into a single
UNION ALLrequest per refresh, so ten configured variables still cost one round trip. It runs concurrently with the panel wave — option SQL cannot reference a variable, so no panel is ever waiting on it. Each branch is bounded independently (1,000 options), and a list cut off at that cap says so.A select never auto-selects: a variable starts unset, its panels wait rather than run, and the inline × returns it to unset. Changing a value re-runs only the panels that declare that variable. Duplicate values collapse to the first row's label.
A combined-query problem is reported as a batch-level failure (every option-backed control unavailable, one banner, no automatic fall-back to N separate queries), because
UNION ALLreports one merged column list for every branch and so cannot say which one is at fault. Narrowing it down means running a single variable's SQL on its own, which its own editor tab's Run action does (#457/#465).Cascading option queries are rejected outright, and
Array/Tuple/Map/Nestedvariables are marked as having no inferred control — they keep their text input, since a literal typed there still binds. (Narrowed by #468 above, in this same unreleased set: anArrayof a scalar WITH option SQL now renders a multi-select. The marker survives for every other container, and for anArray(scalar T)nobody has configured.) -
The no-inferred-control diagnostic no longer renders a duplicate second row (#470). The marker above used to be a sibling element (icon + a repeat of the declared type, e.g.
Array(Int32)) appended beside the input; since it and the input's own wrapper were both pinned to the same CSS grid column, the layout pushed it into a visible second row that read as a duplicate control rather than a diagnostic. It now adorns the SAME input in place — a yellow/dashed class (mirroring the existing invalid/conflict affordances), a decorative icon absolutely positioned over the input, and the full message reachable on hover (title) and keyboard focus (aria-describedby) — with no change to the input's rendered width or the one-row filter layout.
-
Exactly one command creates a Dashboard (#429 phase 3, absorbing #481). File ▸ New dashboard… and the empty-workspace placeholder's Create button now both prompt for a name (seeded with the same default) and commit through
appendDashboard— never the compatibility slot the placeholder used to write through silently. The placeholder still reveals exactly what it created, by the id the commit actually returned. -
A Dashboard tree row is three independent targets: chevron, name, trailing action (#429 phase 2, absorbing #472). Clicking a Dashboard's name now opens it in View — immediately, with no double-click window to sit through — and Shift-click still opens Edit. Expansion belongs to the chevron alone, which became a real button with its own accessible name (Expand Sales / Collapse Sales), its own
aria-expanded, its own focus ring and native Enter/Space, and which never navigates. The trailing⋯does neither.Before this, a single click only expanded the row and opening needed a double-click, so navigation waited ~300ms on every click and the two operations competed for one gesture. They no longer do, and the delayed arbitration is gone from Dashboard rows entirely; a panel row's gestures are unchanged (click opens its query, double-click and Shift-click focus the tile), so it still arbitrates. Keyboard:
Enteron a Dashboard row opens View,Shift+Enteropens Edit,→/←still expand and collapse, and Tab reaches all three of a focused row's targets without turning the tree into one tab stop per chevron.The row's
⋯menu loses Open in View, which is now simply what clicking the row does, and keeps Open in Edit, whose only other forms are the hidden Shift-click andShift+Entermodifiers. A Dashboard the search is holding open still cannot be collapsed (expansion state stays the user's), but its name now opens it, where before that click did nothing at all. -
A Dashboard tile opens its own query in the Workbench; the Dashboard-level
< Querybutton is gone (#471). Every query-backed tile carries an Open in Workbench icon action in its own chrome, in both View and Edit mode and in both layout engines. It opens that tile's query document in the main SQL editor — or selects the tab already showing it — and leaves the Dashboard's filters, layout, scroll position and View/Edit state untouched.Identity is the tile's own query id, never the displayed name. A Dashboard tile references a dedicated saved-query copy that exactly one member owns (#427), so two Dashboards holding same-named copies open two separate tabs, re-opening one selects the tab it already has, and Save from that tab updates the Dashboard's copy rather than a same-named Library query. The compact collision badges that make two same-named tabs tellable apart remain #464.
The action is deliberately not edit-mode-only — inspecting the query behind a tile is a View-mode act first. It is subtle until the tile is hovered or the button is focused, always visible on touch, and pressing it never starts a tile drag, resize, selection or chart gesture. A queryless tile (a
textpanel) and a tile whose query id no longer resolves show no action at all, rather than a disabled control or one pointing at an unrelated document.The removed
< Querybutton named no document — it was ordinary back-navigation occupying primary toolbar space. Two follow-on consequences: a Grid-Tiles KPI tile's chrome overlay now reveals and accepts clicks in View mode too (it had no View-mode control before), and because the mobile rules hide the sidebar for a full-bleed Dashboard, the bottom nav no longer hides itself on the Dashboard surface — it shows only Editor, which returns to the Workbench. Without that, a phone looking at a Dashboard with no tiles would have had no route back at all.Returning with Back now lands on the Dashboard you left, where you left it. The URL deliberately carries no Dashboard id, and opening a query tears the Dashboard down, so Back used to rebuild the first Dashboard in the workspace at the top of the page — tolerable while a global back button existed, and not tolerable once per-tile actions are the way out. Each Dashboard history entry now remembers its own id, current member and scroll offset, so several Back steps across several Dashboards each return correctly; a remembered Dashboard that has since been deleted falls back to the Workbench rather than to some other one.
-
The Dashboard runtime now says "variable" everywhere it meant "variable" (#459). #447 replaced curated Dashboard filters with inferred variables but left the surviving code calling them filters, so the source read as if a model it had deleted were still there. Renamed, with no behaviour change: the runtime types and session API (
ViewerFilterState→ViewerVariableState,applyFilters→applyVariables,DashboardViewState.filters→.variableStates,.filterDiagnostics→.optionDiagnostics, and the rest of thesetFilter/clearFilter/resetFiltersfamily), the time-range pair identities (fromFilterId/toFilterId→fromVariableId/toVariableId), four modules (ui/filter-bar.ts→ui/variable-bar.ts,ui/filter-option-field.ts→ui/variable-option-field.ts,core/filter-width.ts→core/variable-width.ts,dashboard/model/dashboard-filter-store.ts→dashboard-variable-store.ts), and the.dash-filter*/.dash-clear-filters/.detached-filter-row/.filter-selectCSS family..ms-overlaybecame.popover-overlay— not because thems-prefix is gone (#468 restored the multi-select control, and its own fourteenms-*classes are very much alive) but because that one class was never the multi-select's: it is the default backdrop class of the shared anchored-dialog primitiveui/popover.ts, which the time-range control uses too. It now says what it belongs to.The visible vocabulary moved with it: the variable row's section label reads Variables rather than Filters, and the Dashboard, time-range, detached-view and multi-select controls announce themselves to assistive tech as variables ("Dashboard variables", "Query variables", "
<name>variable, N selected"). Nothing else about the controls changed.Saved Dashboard variable values are untouched. The localStorage key stays
asb:dashFilters, keeping its historical name deliberately: renaming it would have discarded every committed value on the next load.asb:filterActiveand its helpers keep their names too — what they denote is activation of a parameter's optional/*[ … ]*/filter block, a live SQL-filter concept that predates the curated Dashboard model and outlived it, and that key is persisted as well. (The shared variable-bar port also exposes that name to its Dashboard caller, where nothing is persisted behind it; that leaky abstraction is #478, deliberately not folded into a rename.) Both exceptions are documented where they live, and a new test pins every persisted key string so a future rename cannot orphan real data while the suite stays green. -
Dashboard variable option SQL is edited in the main editor, as its own tab (#457). Clicking a variable in the Dashboards tree switches to Query and opens a dedicated
Variable: <name>tab on that variable's committed option SQL (blank when it has none), reusing the editor, tab strip, toolbar, Run action and result area every query already uses. Re-clicking the same variable selects the tab it already has; the same variable name in two Dashboards opens two independent tabs, because a variable is identified by its Dashboard and its exact name. Open tabs and unsaved drafts are never disturbed.Save writes only
variableConfigs[<name>]on that Dashboard — never a Library entry, a History row, a favourite or a panel — and blank or whitespace-only SQL removes the configuration, returning the variable to direct input. A Save whose Dashboard no longer resolves (deleted meanwhile, or an ambiguous id) commits nothing and keeps the draft, rather than guessing a target.This replaces the right-side option-SQL drawer introduced in #447 phase 1. The application already had a complete SQL editing surface; a second one in a narrow drawer duplicated that machinery, shrank the working area and ran its own editor lifecycle. The drawer, its editor seam, its textarea fallback and its drawer-local Test action are all gone — running a variable's SQL is now the ordinary Run action. Re-hosting Test's two-
String-column check on that Run shipped as #465, below. -
One File menu for the whole application (#452). Query, Dashboard Edit, Dashboard View, the empty-Dashboard placeholder and the Dashboard workspace-not-found fallback now render the same File menu — same rows, same order, same labels, same trigger behaviour. Before, the same header control changed meaning with the work surface: the Dashboard's own menu offered import/export and dropped every workspace and Library operation, Dashboard View dropped a row again, the two implementations ordered and grouped their rows differently, and only one of the two triggers toggled shut when clicked again.
Context now changes enabled state only — a row never disappears or moves. An unavailable row is dimmed, carries
aria-disabled, is skipped by keyboard navigation, cannot be activated, and states its reason on its own line (Open a dashboard, Edit mode only, No dashboard, Dashboard unavailable, No workspace, No Library queries). The per-surface section headings are gone,Download Markdown/Download SQLbecame Download Library as Markdown/as SQL so their scope is unambiguous when opened over a Dashboard, and the footer reports both counts (N Library queries · M dashboards) from the zero-owner Library projection rather than the raw collection.Dashboard operations now require an exact target.
Export Dashboard…andImport Dashboard…receive the Dashboard the surface actually rendered instead of re-reading the session selection, which returnednullin Query mode and silently made both commands act on the collection's first Dashboard. Import onto the empty placeholder creates the workspace's first Dashboard, and is offered only when the collection is genuinely empty — the placeholder is also reached when a selection stops resolving against a non-empty collection, where that path would have overwritten Dashboard #1. A target removed while the import dialog is open fails closed with a diagnostic and commits nothing.The File menu is 288px wide (from 252px) to fit the longer labels, and the Dashboard-only
.dash-file-menu/.dash-fm-item/.dash-file-btnclasses, theAppHeaderOptions.fileButtonseam, and the untargetedactions.exportDashboard/actions.importDashboardregistry entries are gone. -
Dashboard filters are gone; a Dashboard's variables are INFERRED from the
{name:Type}placeholders in its panels' own SQL (#447, phase 1). There is no persisted filter object any more — no filter id, no label separate from the name, no option-source query reference, no explicit target list, no selection mode. A variable's name and ClickHouse type come from the panel queries that declare it, matched exactly and case-sensitively:countryandCountryare two variables, and a variable binds automatically to every panel query on the same Dashboard that declares that exact name. Adding or removing a panel changes the variable list by itself; creating a variable is never a separate action. The one thing still stored isdashboards[].variableConfigs— optional Dashboard-local option SQL keyed by variable name, holding only the SQL plus an informationallastKnownTypeso an orphaned entry can still show a type.The lower-left tree's Filters group is now Variables, listing
name : Typefor every distinct inferred name plus every preserved orphaned configuration. Declaring one name with two different types produces ONE row showing every type, marked with an error icon and accessible error text (never colour alone), whose hover diagnostic lists the conflicting panels; it renders no working control, is excluded from execution, blocks only the panels that require it, and resolves by itself once the panel SQL agrees. When the last panel reference to a CONFIGURED variable disappears its SQL is preserved as anunusedorphan — warning-marked, hidden from the controls, excluded from execution, still editable, and deletable through a trailing trash icon that asks for confirmation because SQL will be lost. An UNconfigured variable that loses its last reference simply disappears. A variable row has exactly two operations: click to open its option SQL, and (orphans only) delete. There is no overflow menu.Because the tree group was renamed, a Dashboard whose Filters group was expanded opens with Variables collapsed once; session expansion state is not migrated.
Not in this phase (they land with #447's phase 2): the typed direct-input control matrix and its unsupported-compound diagnostics, option-backed single-selects, the strict two-
String-column option contract, theUNION ALLbatch compiler and its one-request execution, per-variable Run/Test, and panel-waits-for-unset semantics. Dashboard controls in this phase are the existing direct-input fields, now fed by inferred variables. In particular, clearing a variable deactivates it but still leaves its last value bound where a panel requires that parameter outside an optional block — the pre-existing behaviour for plain filters, which phase 2's unset/selection work addresses. -
Filteris no longer a saved-query role or a panel type (#447). The role enum is["panel", "setup"], and the result-panel picker is a flat list of real visualisations: theDashboard rolecategory, theFilteroption, thePreview…placeholder and thePanelheading are all removed, along with the Filter result view and its preview. The whole option-provider runtime goes with them — implicit filter synthesis, provider discovery by output-column name, provider merging and duplicate-provider arbitration, identical-provider collapse, shared-source execution dedup, one query feeding several filter arrays, array/multiselect parsing and controls, and per-filter panel targets. A Dashboard tile is now the only kind of member that references a saved query, so "one query, several owners" has no valid form left. -
Stored workspaces are
storageVersion: 5, Dashboard documentsdocumentVersion: 2, portable bundlesversion: 2(#447). v2/v3/v4 records and v1 bundles stay readable and migrate on read: each is validated against its OWN schema at its own Dashboard document version, then carried forward. There is no v3→v4 step any more — filters are dropped first, so the ownership migration only ever walks document v2 and mints a dedicated copy per panel tile. A copy a filter used to own is kept: losing its last owner makes it a Library query, exactly as removing any other member does, so no SQL is destroyed and the query reappears in the Library beside the original it was cloned from. Owned-copy ids still derive to the same values they did before, so nothing is re-minted and no open tab is disturbed.maxQueriesstays at 5224 — removing filters lowers the derived worst case, but a record already at that bound must keep decoding, so it is a compatibility ceiling now rather than a recomputed maximum.One document is deliberately not recoverable: a stored workspace or bundle carrying a saved query with the removed
filterrole fails its structural pass before migration can reach it, and is reported unsupported rather than migrated. The curated-filter representation was experimental with no production legacy, so such a workspace is recreated rather than repaired — delete the stored record (or theasb-workspaces-v2IndexedDB database) and re-import. -
The compound From/To time-range control and the chart crosshair/brush survive (#335/#334), re-based from curated filter definitions onto inferred variables. A range pair is still discovered from the recognized name table or authoritative saved-query
timeRangesmetadata, but a bound is now identified by its variable name and typed by that variable's agreed declaration, so the array/selection-mode gate the old resolver applied is gone. -
Every Dashboard panel owns a dedicated saved-query copy, and the lower-left pane is the Library (#427; #447 removed the curated-filter half of this). A workspace's queries split into two kinds: a Library query, which no Dashboard member references, and a Dashboard-owned copy, referenced by exactly one panel tile. Ownership is derived from those references alone — there is no flag on the query — and no query may be shared by two tiles or by members of different Dashboards. Existing workspaces migrate on open (
storageVersion: 4): every original query stays exactly where it was as the Library source, and each member gains its own copy with the owner's role and no favourite. Dashboard, tile, filter and layout ids, their order, and every Dashboard revision are preserved, so the #426 tree's expansion and selection survive the migration untouched. The clone ids are DERIVED from the member each copy belongs to rather than generated, so opening the same record twice — or in two tabs — produces the same document instead of two tabs fighting over ids. The stored query bound rose from 1000 to 5224 (1000 originals plus the 4224 members a valid workspace can hold), because at the old bound migrating a large workspace produced a record that would fail validation on every future open; portable bundles track the same bound, so an older build will reject a very large new bundle. The encoded-document cap rose with it, 10 MiB to 20 MiB, for the same reason and one step further along: that bound is enforced only when WRITING, so a stored workspace between roughly 5 and 10 MiB used to decode and migrate fine and then fail every subsequent save — permanently read-only, with no repair path.One hand-off to #429 is worth stating plainly: removing a panel from a Dashboard leaves the dedicated copy it owned with no owner, so that copy reappears in the Library beside the original it was cloned from. Deleting a member and its owned query as one atomic operation is #429's trash action; until then the copy is kept rather than silently discarded.
-
The lower pane is
Library | Historyand lists only standalone queries, with a matching count,Search library queries…, and Library-worded empty states; on phones the segment is relabelled the same way. A Dashboard-owned copy stays stored and stays openable from the Dashboards tree — it simply is not a Library entry. Markdown/SQL document exports and the workspace picker's query count follow the same projection, so a migrated workspace no longer exports every panel twice. -
The favourite star is a Library preference, with no Dashboard meaning whatsoever (#427, closes #434). It used to BE membership: starring a query appended a tile — minting a whole Dashboard if the workspace had none — and unstarring removed every tile referencing it, which is why #425 had to refuse starring while a non-first Dashboard was selected. All of that is gone: a star writes
spec.favoriteand nothing else, the refusal and its toast are retired, taking a tile off a Dashboard no longer rewrites the query, and importing a favourited query no longer creates Dashboard content (so a queries-only bundle no longer arrives with a Dashboard attached). Starred queries still sort first in the Library. An implicit filter's option source is now matched by parameter NAME against Library filter-role queries, with the favourite requirement dropped — so an unstarred query can supply options, and a copy some Dashboard already owns can no longer be borrowed as another Dashboard's source. -
A workspace now stores an ordered collection of Dashboard documents (
StoredWorkspaceV3, #424) instead of zero or one. Records written under the previous contract are read and migrated deterministically on open — Dashboard ids, revisions, layouts, filters, tiles, favorites, and unknown forward-compatible fields are preserved exactly, no tile is derived from or removed because of a favorite flag, and nothing is reordered; every new write isstorageVersion: 3. Dashboard ids are unique per workspace and each Dashboard is validated independently against the shared query collection, while tile, filter, and layout ids stay Dashboard-local. No UI change: the Workbench and Dashboard still expose exactly one Dashboard, resolved through a single compatibility selector, and the favorite star drives its membership exactly as before. Additional Dashboards are preserved, validated, exported, and never modified by an action that did not target them. Two import/export consequences: a workspace export now carries every stored Dashboard rather than only the visible one, and Import workspace imports every Dashboard in the file (in file order) instead of asking which single one to keep.
- A Databases / Dashboards switcher and a read-only Dashboard hierarchy tree
(#426). The upper-left pane gains an equal-role tab row over two persistent
hosts, so every stored Dashboard is finally reachable:
Dashboard → Filters → Panels, in collection order, with panel counts, per-role search, expansion, scroll and full keyboard navigation. Switching roles only hides a host, so the schema search text and focus, expansion, lazily-loaded columns and scroll survive by construction — as do the sidebar width and the upper/lower splitter. A panel row opens its query; double-click and Shift-click open the Dashboard in View or Edit focused on that exact tile or curated filter, addressed by Dashboard-local id and never by query id. Those gestures are mutually exclusive: the primary action is deferred for the double-click window and cancelled outright by the second click, so a query never flashes on the way to a Dashboard, and the disclosure chevron stays the instant path for expansion. Repeated member navigation inside an already-open Dashboard is delivered in place through the existing surface command port — no rebuild, no re-run, no extra history entry. The tree is a strictly read-only projection of the committed workspace: it never clones, repairs or rewrites a reference, and it renders a diagnostic row (keeping Dashboard navigation available) for a broken one, while a curated filter that simply has no option source yet is treated as the transitional state it is, not an error. The obsolete headerSQL Browser | Dashboardswitch is gone — the brand zone is now non-interactiveAltinity® SQL Browser— along with a vestigial hidden Dashboard-nav seam that was built but never attached to the page. On phones the sidebar's upper segment is relabelled Explore, since that pane now holds both roles. - A Dashboard is now a full-size main work surface, selected by stable id (#425). Opening one replaces the complete SQL editor and result/data-drawer area — the left sidebar stays visible — and the View/Edit switch lives in the primary toolbar (the separate Back-to-query/title row was folded into the compact toolbar by #437). Returning finds the Query surface exactly as it was: the same editor contents, selection, scroll, active tab, result view, and editor/results split, because the surface is hidden rather than rebuilt. A surface change no longer cancels the query running in the editor, and Back/Forward between surfaces of one workspace no longer tears the editor down. Any Dashboard in the workspace can be opened by its stable id in View or Edit mode, with an optional navigation target that focuses, scrolls to, and briefly highlights one panel tile (by tile id, never query id) or one curated filter (by filter id). Selection is session state — never persisted, cleared on sign-out, and re-validated against every committed workspace, so a deleted or ambiguous selection falls back to Query mode instead of silently switching to another Dashboard; URLs are unchanged. Export Dashboard and Import Dashboard now address the selected Dashboard rather than the workspace's first one; the favourite star, which still drives panel membership through the first Dashboard, declines with an explanation while a different one is open (it is rewired in #427).
- Inter and JetBrains Mono are now shipped with the artifact instead of only
being named in the font stacks. DESIGN.md has always specified both, but with
no
@font-faceand no CDN allowed, they rendered only for users who happened to have them installed locally — everyone else silently got the platform UI face and Menlo/Consolas.build/fonts.mjsinlines latin-subset, upright, variable-weight woff2 as base64@font-facesources: ~89 KB of woff2, +19% gzip ondist/sql.html, still zero third-party requests.unicode-rangeis preserved so codepoints outside the subset (Cyrillic, CJK, the ⌘/↵/→ glyphs) keep falling through to the platform font rather than rendering tofu. The KPI metric gainstabular-numsso a refreshing value no longer twitches. - A named type scale (
--text-*,--fw-*,--lh-*) andtests/unit/typography-contract.test.js, which gates it: everyfont-sizeinsrc/styles.cssmust resolve to a token, no two steps within a ramp may sit closer than 1px, the tokens must match the DESIGN.md frontmatter, no class the UI renders may be left with no CSS rule, and token contrast must clear WCAG AA in both themes. Nothing previously enforced any of this — behaviour coverage cannot see a missing stylesheet. - Surface-aware keyboard shortcuts for SQL Browser and Dashboard (#417). The
shared, platform-aware shortcut catalog now drives both help and dispatch;
Dashboard gains refresh, View/Edit, and
Gnavigation commands while stale viewer sessions, loading routes, stacked overlays, and nested text inputs fail closed. Contextual help is torn down on route and authentication transitions so it cannot survive onto another surface or Login. Dashboard Style addsG G,G F,G R,G 2, andG 3for Grid Tiles, Full view, Report, and two/three columns; View mode previews every style without changing the shared dashboard.
- The Dashboard toolbar is one compact row instead of two (#437). The #425
surface row (Back to query, the Dashboard title) is gone — Back to query
returns as an icon-first control at the START of that single row (#426, once
the header's SQL Browser / Dashboard switch it had delegated to was itself
retired), and View/Edit now sits at the end of the primary toolbar, after a
combined freshness control. That control replaces the separate "Updated
HH:MM" label and text "Refresh" button with the bare time plus an icon-only
refresh action (a spinner while running, a tooltip/
aria-labelnaming the last-updated time once a run completes). That time is a genuine wall clock (DashboardViewState.lastSuccessWallMs, only ever advanced by arefresh()wave that leaves every tile it ran out oferrorstatus) rather than "now" at whatever moment a render happens to run, so it stays stable across an unrelated publish (tile Search, a layout switch) and a refresh that leaves a tile in error shows an accessible "Refresh failed" state instead of silently claiming a fresh time — the last known-good time survives underneath it. The tile-search placeholder shortens to "Search" (its accessible label is unchanged) and the field itself narrows to a ~120–145px responsive range. The empty-Dashboard placeholder gets the same one-row treatment. - Radii, elevation and the semantic/object-kind/syntax palettes are tokens too
(
--r-*,--shadow-*,--ring-*,--kind-*,--role-*,--sql-*). Fourteen radius values against a documented four-step scale became four plus--r-pill; fourteen shadows across eleven black alphas became one token per documented entry..dash-tilewas at 10px while DESIGN.md's own Dashboard Tiles section said 8px. - KPI cards no longer wear a 3px accent bar across the top. The author's per-KPI
colour hint (
presentation.color) tints the card's own 1px frame instead: the bar mitred against the 8px radius, and a coloured stripe on a metric card is the dashboard cliché PRODUCT.md lists as an anti-reference.--kpi-accentnow defaults to--borderrather than--accent, so a KPI with no author hint is neutral and ClickHouse Blue stays scarce instead of appearing on every card. - Reference-doc admonitions state their variant with a 1px frame in the variant colour plus a coloured title word, replacing the 3px left stripe — colour paired with text, per the Evidence Rule. Blockquotes drop to a hairline left rule.
- The streaming progress bar and the column/row resize handles animate
transforminstead ofwidth/height, keeping two high-frequency surfaces off the layout path. The progress fill is full-width and scaled from the left byresults.ts. - The Altinity logomark moved to
src/ui/brand-logo.ts. Its 26 gradient stops are a supplied brand asset, deliberately outside the palette and never to be borrowed for UI chrome; its own module lets.impeccable/config.jsonexclude exactly that file from the design-system colour check instead of all ~60 icons. - The type ramp is collapsed from 22 ad-hoc values to six interface steps
(9 / 10.5 / 11.5 / 12.5 / 14 / 16px) plus a document ramp for Read surfaces
and two display sizes. 231 literal
font-sizedeclarations became tokens, ninefont:shorthands carrying hidden literal sizes were expanded, and five values that sat below the documented 11px floor are gone. The half-pixel neighbours it removed (9/9.5, 10/10.5, 13.5/14/14.5) could not carry hierarchy: at these sizes a 0.5px step buys ~0.26px of x-height, below one device pixel at 1×. bodynow sets an explicit base size andh1–h6reset tofont-size: inherit, so a heading or surface the stylesheet misses degrades to the product's own body size instead of to user-agent typography.
- Three follow-ups on the Dashboard tree (#426). A row the search is holding open no longer offers a toggle at all — its chevron gets no handler, and click, ArrowLeft and ArrowRight leave expansion alone — because a search exposes paths without mutating saved expansion state, so a toggle there wrote state that was invisible until the search cleared and surprising afterwards. Opening a Dashboard focused on a tile or filter it does not contain no longer marks that phantom member as current, while the focus request still survives unchecked so the delivery path keeps reporting the miss. And a deferred single-click is now cancelled by every projection rather than only a workspace switch: deleting a Dashboard inside the double-click window could otherwise let the delayed toggle re-add a just-pruned id, or open a dead query id.
- The shared anchored-dialog focus trap (
openAnchoredDialog, #439) now owns every Tab/Shift+Tab transition instead of only wrapping at the first and last declared focusable element. Native sequential-focus policy is not portable: WebKit/Safari's default "Tab highlights every item on a webpage" preference is off, so browser-delegated middle-of-list traversal could skip checkboxes and buttons entirely — in the multi-select and time-range popovers this meant keyboard focus could never reach Clear/Cancel/Apply, or escape the modal trap altogether. The eligible set is still recomputed on every press (disabled/hidden/removed controls drop out immediately, restored ones rejoin without reopening the dialog), and a focused element no longer in that set is treated as unfocused (Tab goes to the first eligible element, Shift+Tab to the last). Verified in Chromium, Firefox, and WebKit. The primitive also gainedhandle.reclaimFocus(): disabling the currently-focused control (the multi-select's busy state disables every row but Cancel while a Filter source is loading) natively blurs it out of the dialog entirely, past the trap's dialog-scoped reach — a plain Tab press right after would never re-enter the modal. Multi-select now calls it the moment busy state lands, recovering focus onto Cancel instead of leaving it stranded on the page behind the modal. - The Dashboard surface (#425) is brought onto the token system it was written
before. Its title was
13px/600— half a pixel above--text-body, a step the One-Pixel Floor forbids because nobody can see it — and now steps up by weight instead, at--text-body/--fw-semibold, one weight above the--text-labelcontrols beside it. Its navigation highlight mixed its own translucent accent (in srgb … 25%) beside the ring family'sin oklab … 22%: one halo in two spellings, already drifting. It is--ring-navnow, a fourth member of that family. That highlight also forcedborder-radius: 6pxonto whatever it marked, so a--r-mddashboard tile visibly changed shape while highlighted and the ring mitred against the corner it was pointing at; it now sets no radius and follows the target's own. A deadgap: 8px— overridden by.dash-toolbar's later, equally specificgap: 10pxsince the day it landed — is removed, so the surface toolbar and the filter row beneath it are visibly one sticky bar. - The pane-splitter indicator is a real
1pxline again. Moving it off the layout path (see below) had left it a3pxbar held down byscaleX(.34), which painted1.02pxand, worse, reported3pxto anything reading the computed box — sosplitters.spec.jshad been failing on this branch, unrun, since that change. It now scales up from an honest1px, and the spec measures the painted extent (layout size × the transform's own scale) rather than the untransformed box, which is what it always meant to assert. - Three gaps in the design-system gate, each of which is why the above shipped.
The shadow check tested for
rgba(only, so a hand-mixedcolor-mix(…, transparent)passed; 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 splitapp.tsintoapp-shell.tsandworkbench/workbench-shell.ts— moving the application frame, the sidebar and both surface hosts out of its view; the list follows the markup now. - Two DOM state badges (
.qtab-external,.mnav-badge) were sitting on--text-nano, the 9px tier DESIGN.md reserves for SVG text inside the zoomable graphs — one because the mechanical 9px→token mapping put it there, one because it was authored in the same change that wrote the restriction. Both now use--text-micro, the documented tier for state badges. The contract test now enforces the restrictions themselves rather than only that some token is used:--text-nanois confined to the graph surfaces, the display tier to the login mark and the KPI metric, and the document ramp to Read surfaces. font-src 'self'blocked both inlined typefaces in every real deployment. CSP'self'is an origin match and does not cover thedata:scheme — it has to be listed explicitly, exactly as the neighbouringimg-src data:already did. Fixed in both configs that ship a policy (deploy/http_handlers.xmlanddeploy/nginx/default.conf.template). The failure was invisible by construction: a blocked@font-facerenders no tofu, it falls through to the platform face and looks correct, so the deployed app would have kept using system fonts with only a console violation as evidence — while every local check passed, because the CSP exists only in the deploy configs.tests/unit/csp-contract.test.jsnow asserts every URL scheme the built artifact references is permitted by the corresponding directive, in every config that ships a CSP, and that those configs agree.--fg-faintmet 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 — row counts, capped/cancelled badges, tile footers, the "Press ⌘↵ to run query" hint. Now 4.61:1 light / 4.79:1 dark against their worst backgrounds (WCAG 2.2 AA 1.4.3).- ClickHouse Blue as text failed AA too (4.10:1 on light chips, 3.83:1 on dark
surfaces). Text now uses
--accent-text— the palette's existing Deep ClickHouse Blue in light theme, a lifted#2596CCin dark. Fills, borders, focus rings, carets and icons keep--accent, where the 3:1 non-text threshold applies. - The destructive Overwrite button in the linked-tab conflict chooser uses a new
--error-fillrather than--error-fg. A foreground token is tuned to be legible AS text on its plane, so dark theme's#f87171carried white at only 2.77:1 as a fill.--error-fillis dark enough for white text in both themes (4.98:1 dark, 6.47:1 light) and keeps a visible edge against the dialog behind it. - Six surfaces shipped with no CSS rule at all and therefore rendered in browser
chrome. The linked-tab conflict chooser (#343) — the dialog that decides
whether to overwrite work saved in another tab — had 13.333px Arial buttons
with
2px outsetborders and a title visually identical to its description. The query-tab marker warning that a linked query changed or was deleted rendered as an unstyled stray!/⌫character.workspace-not-foundhad a 32pxh1and a raw#0000EEunderlined link;workspace-loadingand two dashboard empty states had 19.5px user-agent headings. All are now typeset in the product's own vocabulary, with the destructive Overwrite action carrying error tokens and the safe Reload action as the accented default.
- The variable bar's
updateStatusseam and itsFieldStatustype (#460).updateStatuswas the per-field execution-status affordance the curated filter model published through — #447 phase 1 deleted every producer, but kept the consumer side as a documented no-op for a later, non-rebuild affordance to land through. Phase 2 and #457 both landed without ever needing it, and #460 confirmed it has zero production callers (only test callers remained), so the bar-level fold, theFieldHandle/VariableBarHandlemethod, and the three no-op implementations (the multi-select field, the option-backed select, and the time-range control intime-range-field.ts) are gone.ViewerVariableState.options/optionsRevwere audited too and kept — phase 2's batched option runtime is a real, live producer of both.
- Remove obsolete
issandhdlogin-link hints from SQL Browser URLs. IdP selection remains owned by the deployment configuration and session state.
- Keep Grafana-grid KPI hover chrome control-only: saved-query titles and descriptions no longer overlay metric values while moving or editing a tile.
- Keep the shared connection host visible after the server-version probe, restore Dashboard filters to their initial default-derived active state, and retain Dashboard time-range announcements when no ordinary filters exist.
- Restore Dashboard tiles for favorited panel queries imported through either query-only or query-only workspace imports, so the imported report opens instead of incorrectly offering only “Create dashboard”.
- Unified SQL Browser and Dashboard chrome. Both surfaces now share the same brand, surface, workspace, connection, examples, shortcuts, theme, and user zones. Dashboard layout, tile count/search, time range, refresh, and View/Edit controls live in a sticky primary tool row; ordinary parameters live in a separate sticky filter row. Tile search matches effective titles and descriptions without rerunning queries or changing saved layout/order, and ordinary-filter Clear all restores defaults in one execution wave while preserving every time-range pair.
- Unified
/sqlrouting for Workbench and Dashboard surfaces (#407). Workspace identity, surface, and presentation mode now live in canonicalws,surface, andmodequery parameters. Workbench/Dashboard switches stay in the same tab with useful Back-button history, while View/Edit uses replacement history. Dashboard view mode renders the same live workspace document without authoring controls; an explicit missing workspace never falls back, and an empty workspace offers Create only in edit mode. The old surface becomes an inert loading route before cross-workspace navigation, while same-workspace Back/Forward switches immediately. Dashboard uses the shared application header and route controls. Global Workbench shortcuts fail closed outside a ready matching route, and renderer generations let in-flight durable writes finish without obsolete Dashboard or Workbench callbacks repainting the selected surface. Direct Dashboard startup now shares the authenticated server-version probe. Empty Dashboard routes also react when another tab creates the Dashboard. The old/sql/dashboardbootstrap split, detached Dashboard snapshot stores, one-time state handoff, and cross-tab credential handoff have been removed. - Multi-workspace local persistence foundation (#406). Stored workspaces
now use the V2 contract with separate immutable opaque
id, immutable human-readable URLkey, and mutable displayname. IndexedDB stores one validated aggregate per workspace behind an ID key path and unique key index; the repository can list summaries, load by ID/key, create, replace, and delete individual records without affecting neighbors. Last-used key and injected-clocklastOpenedAtmetadata select the most recently opened valid workspace for implicit startup, while explicit?ws=lookup never falls back. Workspace import is additive with reminted local identity, Dashboard links resolve stable keys, and active edits reload/commit by immutable ID.
0.6.2 - 2026-07-23
- Make the Dashboard multi-select Apply button remain a clear primary action on hover, while preserving distinct disabled, keyboard-focus, and pressed states (#386).
- Declared Node.js 22 as the minimum source-development runtime, kept the
committed
.nvmrcas the version-manager default, and enabled strict npm engine checks so unsupported local installs fail before the test runner reaches incompatible dependencies (#383).
- KPI queries now have one frameless Dashboard presentation in Grid Tiles, Full view, Report, 2-column, and 3-column styles. Flow KPI-band members and transparent grid wrappers move with Command/Ctrl-drag while plain dragging continues to select text; movement reuses the shared floating preview, destination resolution, auto-scroll, cancellation, and exact-once commit path. Grid KPI title/delete/resize chrome appears transiently on hover or keyboard focus without shifting content, and resize is keyboard-operable with arrow keys (#340).
- Consolidated the non-Iceberg examples into three responsive flagship
dashboards: On-time flights, Shop analytics, and ClickHouse Operations.
Each uses an authored
grafana-grid@1layout with a completeflow@1fallback, shared filters, and focused visible tiles; useful secondary analyses remain untiled Library entries. Removed the superseded KPI, logs, query-log, System Explorer, and earlier Grafana-port fixtures and generator. - CI now classifies pull-request changes before starting expensive jobs:
example-only changes run focused bundle/contract checks, while unit coverage,
build/size, release-bundle smoke, browser E2E, and Docker jobs run only for
relevant paths. Pushes to
main, release tags, and manual dispatches retain full validation, with a nightly cross-browser E2E safety run and stable gate.
- Saved-query time-range metadata and synchronized Dashboard chart
interaction (#334). Query Spec v1 now carries zero or one authoritative
{from,to}parameter pair. Normal saved-query create/SQL-update flows infer one conservative recognized date/time pair when metadata is absent, while explicit[]opts out and Dashboard viewing never writes metadata. The Dashboard derives runtime groups from that saved metadata, synchronizes an exact-time vertical crosshair across compatible temporal charts, and lets a plain mouse drag select a forward or reverse range. Selection formats each bound through its declared Date/Date32/DateTime/DateTime64 contract, commits and activates both filters atomically, reserves affected generations before asynchronous preflight/source work, and reruns each dependent tile once; Command/Ctrl-drag remains tile movement and manual filter inputs remain the keyboard-accessible fallback. - Cross-tab workspace consistency: refresh before write and stale-tab
invalidation (#343). Workbench and editable-Dashboard tabs editing the same
workspace now stay consistent under normal sequential editing. Every
interactive workspace mutation flows through the shared read-before-write
primitive (
app.mutateWorkspace), which loads the latest committed IndexedDB workspace inside the write queue, applies the operation as a semantic transform over it, projects committed truth exactly once, and broadcasts a smallworkspace-changedinvalidation (never the workspace body) on aBroadcastChannel('asb:workspace')seam; window focus / visibility provide the fallback when the channel is absent or a message was missed, and duplicate notifications coalesce into one queue-ordered reload guarded by a snapshot-token compare. Saved-query create/save/rename/ delete/star no longer commit candidates built from stale in-memory state — unrelated changes made in another tab survive, and operations whose target was deleted externally abort instead of resurrecting it. On an external change, a clean linked Workbench tab silently adopts the new version; a dirty one keeps its draft and enters an explicit conflict state whose Save button opens a two-action resolution chooser (Reload saved version / Keep my draft behind an explicit Overwrite confirmation); a dirty tab whose query was deleted elsewhere becomes an unsaved draft (Save-as-new only). An editable Dashboard rebuilds its viewer session from committed truth — even when only a referenced query changed and the Dashboard document is byte-identical — deferring until pending command descriptors settle and preserving per-Dashboard filter values; detached read-only Dashboard views ignore primary-workspace invalidation entirely. No repository CAS, schema change, or simultaneous-commit protection is involved (#343 non-goals; membership semantics stay with #370).
- Dashboard tile deletion now keeps Workbench star membership consistent
(#370).
dashboard.tiles[]is the canonical favorite state for panel-role queries: deleting the final tile clears the compatibilityspec.favoriteflag, while deleting one of several instances keeps it set. The same atomic workspace transform removes the selected tile from explicit filter targets, normalizes the active layout/fallback, and advances the Dashboard revision once. Legacy or importedfavorite: truepanel queries without a tile now render unstarred and one click creates membership while repairing the mirror flag; filter/setup favorites keep their independent compatibility behavior. - Migrated the development test stack to Vitest 4 (#372). Vitest and its V8 coverage provider now use the supported 4.x line, removed pool options have been migrated, stricter mock typings are explicit, and the more accurate AST remapping is backed by focused edge tests without lowering any per-file threshold. Real CodeMirror fixtures are destroyed between app tests so DOM observers cannot leak into the next case.
- Hardened the development test stack (#366). Vitest and its V8 coverage
provider now use the patched 3.2.x line, Happy DOM now uses 20.11.0, and the
lockfile resolves the patched transitive dependency graph.
npm auditis clean; the change affects local/CI tooling only, never the shipped artifact. - Dashboard tiles now respect an explicit saved
view: "table"(#368). The compatibility form resolves to a Table base presentation before runtime panel detection, while an explicitpaneland its variants/overrides retain their existing precedence.
-
Compound time-range control for Dashboard filter bars (#335). A pair of scalar date-like filters whose parameter names match #334's recognized table (
from/to,from_time/to_time,start/end,start_time/end_time, case-insensitive — neverstart/stop) and whose executable consumers agree on date-like scalar contracts now renders as one compound control in a Time section ahead of the other filters, replacing the pair's two individual fields (source-backed/curated filters never group; every non-group filter keeps its existing control). The closed trigger shows the range resolved against the most recent execution wave's shared clock — no ticking timers — with the raw tokens carried in the accessible name; the popover stages token-or-absolute From/To edits with live resolved previews, per-field relative-time constants (typing filters the list; selecting stages, never commits), group-scoped session-only Recently used ranges (immediate apply), and an explicit Apply gated on both bounds resolving withfrom ≤ to(equal instants permitted). Apply commits both bounds atomically through the new publicDashboardViewerSession.applyFilters(entries)batch API — one publish, one execution wave over the union of both parameters' resolved targets; a failed or identical draft commits nothing. Pair discovery sits behind a resolver seam so #334's saved-querytimeRangesmetadata can replace the interim name inference without touching the UI. Absolute bounds are now validated locally (parseAbsoluteInstant: preview formats, ISO-Tvariants, epoch digits for DateTime types, real calendar checks, years from 1900). The popover chrome is a shared primitive (ui/popover.tsopenAnchoredDialog) extracted from the #189 multiselect — both controls now consume it — andfixedAnchorgained an opt-in pure viewport clamp for narrow screens. -
Searchable multiselect for query-backed Dashboard filters (#189). A source-backed filter whose executable consumers agree on one
Array(T)parameter type now renders a dedicated searchable-checklist control: the closed trigger showsAll/Not set/ the selected label /N selected, and the popover offers a labeled search, a tri-state Select visible scoped to the filtered subset, per-option checkboxes, and Clear / Cancel / Apply — edits stay in a local draft until Apply, which canonicalizes by option order, commits at most once, and triggers at most one targeted panel wave (a no-op Apply issues nothing; Cancel/Escape/outside-click discard the draft and return focus to the trigger). The effective mode is inferred at runtime from the agreed consumer type (scalarT→ single,Array(T)→ multiselect) and can be overridden per filter with the new optionalDashboardFilterDefinitionV1.selection.mode("single"on an array contract commits[value]); inference is runtime-only and never written back into the dashboard document. A helper is exposed only when every executable consumer (the filter's resolved targets plus any dependent Filter sources) agrees on one compatible type — conflicting scalar/Array or element types, nested arrays, undeclared targets, target-less configurations, an explicitmultipleon a scalar contract, or an unknown mode all fall back to the ordinary string input with persistent path-precisefilter-selection-*diagnostics (never a silent downgrade). "Executable consumer" is defined once (gatherExecutableConsumers) and shared by contract resolution, the per-wave helper merge, and whole-workspace semantic validation —validateDashboardSemanticsruns the same resolver at authoring/import time, mapping each diagnostic to its exact document path (filters[i].selection.mode,filters[i].targets[j],filters[i].parameter), and a declaration outside the resolved consumer set (a presentation-error tile, a never-executing cascading-invalid source, a non-targeted tile) can never suppress a valid helper. Committed multiselect values stay realstring[]arrays end to end — through viewer state, localStorage persistence, structural equality, and the existing typedArray(T)serializer (duplicates removed, empty-string elements valid, never comma-joined; an active empty array serializes as a real[]— activation is decided exclusively by the active flag, never by a value sentinel). Option refreshes reconcile by bound value: surviving selections stay active in canonical order (label/order-only changes rerun nothing), removals join one reconciled panel wave, an empty intersection deactivates the filter keeping its dormant value, and new options are never auto-selected; a refresh that lands while the popover is open cancels it (announced via a live region) so a stale draft can never be applied. Filter commits now plan their panel wave from the filter's resolved targets (explicittargetselse declaring tiles) instead of rerunning every tile that merely declares the parameter name, and a failed source degrades the curated control to a usable free-text input (raw values still flow through the typed pipeline) instead of a disabled one. -
Parameterized Dashboard Filter sources with single-layer dependencies (#360). A Filter-role source query may now declare its own
{name:Type}query parameters and bind committed root Dashboard filter values through the existing nativeparam_<name>pipeline (no textual SQL interpolation) — so a query-log option source can compute its user / query-kind / exception-code lists for the selected{from:DateTime}/{to:DateTime}window instead of a hard-coded range. A source waits (issues no ClickHouse request) until every required root parameter is committed, active, valid, and serializable; relative values (-1d,now) resolve against one wall clock per wave; changing a root filter reruns only the sources that declare it, then the affected panels, in one combined wave; a refreshed option set that drops a filter's active value deactivates it before panels run; and a Filter source that depends on another source-backed parameter is rejected with a visible diagnostic (cascading Filter sources are unsupported — single-layer only). Dashboard execution and the Workbench Filter preview share one preparation operation (prepareFilterSourceincore/filter-execution.ts), so structural diagnostics, relative-value resolution, optional-block materialization, validation/serialization, and native arguments are identical on both surfaces. Source-backed filters render a waiting / stale / error affordance in the filter bar (chosen by source topology, not transient status) instead of silently degrading to a plain control; a superseded or session-destroyed commit can never publish stale results or run its panels, and a dependency change clears the affected options so a stale set can't appear current while the new source loads. Filter execution injects noreadonlysetting (kept from #359).examples/query-log-explorer.json'sqle-filtersource is migrated to a{from:DateTime}/ optional{to:DateTime}window (matching its panels, wheretois optional and means "up to now") as a worked example. -
Favoriting a Filter-source query auto-binds it to a matching Dashboard filter (#189/#364). A favorited
filter-role saved query whose top-level output column name equals an (otherwise implicit) panel-tile parameter now attaches its option list to that parameter automatically — the field upgrades from a plain text box to the query-backed control with no authoredDashboardFilterDefinitionV1and no per-filter settings (single vs. multiselect is still inferred from the consumer type). Binding is pure name-matching against the source's parsed output columns (core/select-columns.ts): a parameter produced by exactly one favorited source binds; a parameter produced by zero or by two-or-more (ambiguous) favorited sources stays a plain input rather than guessing. Runtime-only — the synthesized binding is never written back into the dashboard document.
- One wall-clock snapshot per Dashboard execution wave (#335). Each wave
(initial load, Refresh, per-tile refresh, filter commits, dependent
filter-source waves) now captures a single
wallNow()reading, threads it through every relative-token resolution it performs, and publishes it asDashboardViewState.waveWallNowMs. Previously one refresh took several independent clock readings, so relative tokens (now,-1d) in different sub-phases of the same wave could resolve to instants seconds apart. - Saved-query, workspace, and Dashboard persistence stay consistent (#365).
Starring a panel query now creates the workspace's Dashboard and first tile
atomically even when the committed aggregate previously held
dashboard:null; applying New/Open/Replace workspace clears dangling saved query links from still-open editor tabs so Save can create the query instead of silently rejecting it; and Table/JSON result-view changes now dirty and update a linked query's Spec, with linked Save persisting the active Table/JSON/Panel view while preserving a Filter role's dormant view. - Removed a stray NUL byte embedded in
dashboard-viewer-session.ts'soptionsSignature(introduced by #361), which silently made the file look binary to plaingrep/rg. - Dashboard filters sharing one Filter-source query now populate (#359).
When several Dashboard filter definitions referenced the same Filter-role
source query, the viewer ran that query once per definition and keyed each
provider by the filter-definition id — so
mergeDashboardFilterHelpersrejected every helper as a duplicate provider, every control rendered empty, and the explanatory diagnostics were silently discarded. The filter runtime is now split into per-definition state and oneFilterSourceRuntimeper uniquesourceQueryId: the source SQL executes exactly once per refresh wave no matter how many filters/parameters it feeds, the provider is keyed by the saved source-query id (two DISTINCT sources providing the same helper still correctly collide with no arbitrary winner), and the merge's diagnostics (info/warning/error, severity preserved) are now published to the Dashboard instead of dropped — including afilter-helper-missingwarning (naming the source and column) when a source succeeds but omits a consumer's helper, so that filter no longer renders as an unexplained empty control. Each source also retains its last-known provider on its runtime and the merge reads the complete set, so a future selective wave (#360) can re-merge unaffected sources without clearing or rerunning them. This is the one-source-runtime boundary #360 builds on.
- Dashboard Filter execution no longer injects
readonly(#359). Server read-only policy belongs to ClickHouse user/profile configuration, not Dashboard/Workbench feature code;filterExecutionkeeps only its result-format/byte-cap transport settings. Panel execution is unchanged. - Filter option state is honest on failure (#359). A source failure,
missing helper, type conflict, or duplicate-provider collision now CLEARS a
filter's curated options (with a visible diagnostic) instead of silently
retaining stale ones, and an unresolvable
sourceQueryIdsurfaces a visiblesource-errorrather than a silent plain field. An active value no longer present in refreshed options is deactivated (its value kept) before dependent panels run; a non-empty → different-non-empty option replacement now updates the rendered combobox (a per-filter option revision folded into the filter-bar rebuild signature); and a superseded refresh wave can no longer publish options or activation over a fresher one.
0.6.1 - 2026-07-21
- Workbench File menu reorganized around primary workspace actions, with
Replace workspace…renamed toOpen workspace…(#342). The first four rows are now one unlabeled group in a fixed order — New workspace…, Open workspace…, Export workspace… (.json), Import queries… — with theIMPORT / REPLACEandEXPORTsection headings removed. Below a separator,Share / Publish(Download Markdown/SQL) now comes beforeVariable history(recent-values toggle + clear-all); the query-count footer stays last.Open workspace…is a pure rename — the picker, transactional planner (planReplaceWorkspace), conflict resolution, and destructive-replace confirmation are unchanged, with the confirm dialog and success toast copy updated to match ("Open workspace?" / "Opened workspace"). NewIcon.folderOpen()(ui/icons.ts) replaces the reusedrefreshglyph on that row. The standalone Dashboard's own File menu (#302) is untouched. - Compact, type-aware widths for every
{name:Type}variable/filter input (#345)..var-inputno longer reserves a flat 150px for every field regardless of its declared ClickHouse type — a new purefilterWidthCategory/filterInputWidthCh(core/filter-width.ts) resolves a stablech-unit width from the type's effective base (Nullable/LowCardinalityalready unwrapped byparseParamType), or from'enum'for a dropdown/curated field: boolean/tiny-int 9ch, numeric 13ch,Date13ch,DateTime/relative-time 17ch (narrower thanDateTimeeven though both share the same relative-time combobox control), enum/curated 14ch, generic string/UUID/unknown 16ch. Applied exactly once per field build via the sharedapplyFieldWidth(ui/var-field.ts, next to the existingapplyFieldState) from both{name:Type}surfaces — the Workbench var-strip (ui/app.ts) and the Dashboard/detached-view/curated shared filter bar (ui/filter-bar.ts) — through a--var-input-chCSS custom property, so the width never shifts while typing. The curated Dashboard Filter field's inline clear-button padding (18px wider than a plain field's) is compensated in its own.filter-select .var-inputrule so its usable text room matches a plain field at the same width category. Long values still scroll horizontally inside the input; combobox/curated popups are unaffected and can render wider than their input. - Dashboard tiles move with a grip drag + live reflow, plain drags select
text, and table/log cells open the shared cell-detail drawer (#332). Native
whole-card HTML5
draggableis removed. A tile move now starts from the top-left grip with no modifier, or from anywhere on the tile body with ⌘/Ctrl held (the schema-graph modifier model) — so an unmodified body drag selects and copies text inside Markdown/text, table, and logs tiles instead of dragging the tile. On the Grid Tiles (grafana-grid) engine the dragged tile lifts and follows the cursor while the other tiles reflow live to open a gap at the insertion point; the move commits to whichever slot the dragged tile overlaps by the greatest area (max-overlap,resolveOverlapInsertIndexincore/tile-reorder.ts— replaces an earlier ≥2/3-of-destination-area threshold that a short tile, e.g. a KPI, could never reach against a taller neighbor and so always snapped back) and otherwise snaps back when it still overlaps its own origin most or overlaps nothing — the snap-back restore is synchronous and independent of the signature-gated grid reconcile. Sibling motion uses a FLIP animation that honorsprefers-reduced-motion. The flow engine also lifts the dragged tile to a floating follower under the cursor, but still resolves its destination by the simpler point-hit-test (its KPI band has no grid slot to live-reflow into, so there is no placeholder or FLIP there). A move still dispatches the existing atomicmove-tilecommand exactly once and preserves canonicaldashboard.tiles[]order; a cancelled gesture (pointercancel / window blur / Escape) changes nothing, and the click a completed move would synthesize is suppressed so it never activates a cell. While ⌘/Ctrl is held the grid shows a grab affordance (.dash-grid.modkey). Dashboard table cells and log fields now open the same right-side cell-detail drawer used by Workbench results (sharedopenCellDetail, no Dashboard-specific viewer) — in both edit and read-only modes, over the Dashboard's own document — passing the source column name/type and the raw (untruncated) value; each source-backed log field (time, level, message, and each extra) is independently clickable and keyboard-operable (Enter/Space). A cell value that looks like Markdown now opens the drawer on a rendered preview (with a Rendered↔Source toggle, like HTML) using the same bounded, fail-closedrenderDocMarkdownviewer the reference-docs pane uses; plain, non-markup text still shows as source only. Dashboard Text (Markdown) tiles now render inline through that same top-quality doc viewer (the lightweightcore/markdown-lite.tsrenderer is retired — the app has a single Markdown paradigm), and a Text tile is click/Enter-Space-openable into the shared preview drawer (a drag-select or an inner-link click never opens it). Read-only Dashboards never enable tile movement. - Grid Tiles is the default Dashboard style; Full view replaces the old
Full width preset (#321). The
grafana-grid@1engine is renamed to Grid Tiles in the UI (the persisted{type:'grafana-grid',version:1}identifier is unchanged) and is now the layout every newly created Dashboard starts in (emptyitems, acolumns-2flow fallback; new tiles keep the span-6/height-2 default). The oldflow@1/full-widthpreset is removed completely — from the flow JSON Schema and generated types, flow normalization/render, the selector,is-wideCSS, legacy layout mapping (widenow maps toreport), grid→flow fallback generation (nowcolumns-2), and all fixtures/tests; valid flow presets are now onlyreport,columns-2,columns-3. Full view is introduced as a transient, never-persisted render mode over Grid Tiles: every tile renders one-per-row at the full effective column count (12/6/4/2 responsive) while the authored spans are left untouched. Toggling Grid Tiles ↔ Full view runs nochange-layout, never commits, and never bumps the Dashboard revision; a reload or a newly opened viewer session always starts in Grid Tiles. Selecting Full view from a flow preset performs exactly one persisted flow→grid conversion, then only the transient override; selecting a flow preset from Full view clears the override and persists the flow change. While Full view is active, reorder/add/delete and vertical height changes still persist, but the corner resize becomes vertical-only (ans-resizeaffordance with a "Resize tile height" label) — horizontal pointer movement can never change a span. The editable style selector is orderedGrid Tiles, Full view, Report, 2 columns, 3 columns; a read-only Dashboard exposes a reducedGrid Tiles / Full viewruntime toggle (only when its layout is grafana-grid — a read-only flow Dashboard shows no selector), and the selector's accessible name is nowDashboard style. Export/import never carry Full view state. There is no read-compatibility path forflow@1/full-widthdevelopment data (owner decision — the project has no production compatibility requirement for it). - Grafana-grid KPI tiles are polished in both Dashboard modes (#316,
follow-on to #291). Edit mode keeps the full editing shell but drops the
never-populated tile footer (no more phantom separator line). View mode
strips all outer tile chrome — header, border, background, radius, body
padding — leaving the KPI cards (or the loading/unfilled/error state card,
now full-width with
role="status"/role="alert"and the tile title in its accessible name) as the only visible surfaces; the tile element stays the CSS-grid item and carriesrole="group"+ the query title as its accessible name. KPI cards inside grid tiles lay out as an equal-width responsive grid (auto-fill, min 150px — a lone last-row card no longer stretches) with container-query value typography (clamp(16px, 14cqi, 38px)). Shared card polish everywhere (workbench, flow bands, grid tiles — owner decision pinned on #316): the value's number+unit render as glued spans that never orphan the unit to its own line, descriptions clamp to two visual lines (full text stays in the DOM), and delta rows anchor to the card bottom for consistent rhythm. Flow-band stream layout, ordinary tiles, schemas, persistence, and KPI value semantics are unchanged. New real-browser e2e suite (tests/e2e/dashboard-grid-kpi.spec.js) covers the frameless view, equal widths, unit orphaning, delta alignment, 12/6/4/2 responsive columns, themes, and 360px no-overflow.
- Long Dashboards auto-scroll while a tile is being dragged (#338, extends
the #332 grip / ⌘/Ctrl tile move). Once an active move crosses the movement
threshold, holding the pointer near the top or bottom edge of the visible
.dash-pageviewport scrolls it continuously — direction and speed follow the pointer's distance from the edge (bounded, proportional acceleration; a lower bounded speed underprefers-reduced-motion), and scrolling continues with the pointer held stationary. The destination preview recomputes every frame the page scrolls (captured tile home rects are shifted by the scroll delta, so a stationary pointer lands on the newly revealed slot), yet the move still persists exactly one atomicmove-tilecommand on release and nothing during the gesture; a cancel (pointerup outside / pointercancel / window blur / Escape) changes neither tile order nor revision. The effective top edge sits below the sticky Dashboard topbar. Plain text selection, chart brushing, cell interaction, tile resize, and read-only Dashboards never start auto-scroll. The edge-velocity math and the single-requestAnimationFrame-loop controller are a pure, injected-seam module (core/dashboard-autoscroll.ts,DragAutoScrollTarget+FrameScheduler) so a future nested Dashboard scroll container can reuse the controller unchanged; both the Grid Tiles live-reflow and the flow (Report / 2-column / 3-column) reorder paths auto-scroll. Fixed alongside: the grabbed tile now floats (position:fixed) and follows the cursor in every editable layout, not just Grid Tiles — a flow-layout tile used to stayposition:staticwhile dragging, so auto-scroll carried it off-screen with the rest of the page instead of keeping it under the pointer; this covers the KPI band tile as well. No new bundled runtime dependency. - Broad version-exact reference via
system.documentation(ClickHouse 26.6+) with safe Markdown rendering (#60 Phase 3, #315). Servers 26.6 and newer contribute the full breadth ofsystem.documentation— table functions, settings, MergeTree/server settings, dictionary layouts/sources, skipping indexes, disk types, combinators, and future unknown kinds (rendered with their preserved server label) — as the fallback and depth source behind the structured loaders. Parsed pre-26.6 servers are short-circuited without a single query or probe; 26.6+ and unknown-version connections get one silent capability probe (sourcecolumn optional — real 26.6 builds ship only name/type/description). Markdown bodies are tokenized bymarked(the sixth bundled runtime dependency — used strictly as a pure lexer;marked.parse()/HTML output is never called; measured +44 KB raw / ~3% artifact delta) and the token tree is projected 1:1 into strict DOM construction bysrc/core/doc-markdown.ts+src/ui/doc-markdown-view.ts— noinnerHTMLanywhere, an injected https-only link policy, recursive inline fidelity (a link inside bold stays a link), and Docusaurus:::tip/note/info/warning/danger/importantadmonitions rendered as styled asides: headings (setext included), lists, quotes, tables, thematic breaks, and fenced code with exact-copy buttons and ClickHouse highlighting for SQL fences; raw HTML, images, and policy-rejected links stay visible as literal text (fail-closed); byte/node/nesting/link limits truncate quietly (fixed-seed fuzz-tested never-throws contract). The classifier gains strong contexts for SETTINGS names (query-level and MergeTree DDL), FROM/INSERT INTO FUNCTION table functions, CODEC lists, skipping-index TYPEs, andsystem.*tables; F1 on an unresolved bare word now opens an accessible disambiguation list (kind + name + summary, back-stack integrated) instead of doing nothing. A visually secondary "View latest on clickhouse.com" link appears only when safely derivable from the entry's source path — the app never fetches the public site. types** (#60 Phase 2, #314). The documentation pane and F1 now cover four more entity kinds, each fed by its own capability-probed system table (system.formats— which has nosyntaxcolumn and is never asked for one —,system.table_engines,system.database_engines,system.data_type_families; probed independently once per connection, so a denied source degrades alone). A pure strong-context SQL classifier routes the caret: top-levelFORMAT <name>(either FORMAT/SETTINGS order, never confused withformat()calls),ENGINE = <name>in table vs database DDL (leading comments handled), and strong data-type positions — CREATE column definitions,CAST(x AS T),x::T,{p:T}— resolving the innermost nested type under the caret. Format completion info gains Open reference; schema surfaces gain keyboard-accessibleOpen engine reference/Open type referencebuttons (tree rows + detail pane, existing gestures untouched, hidden when the source is durably unavailable). The pane renders the new kinds with syntax blocks, capability facts, and related entries navigable in place through a bounded session-local Back stack. - Version-exact function reference docs from the connected server (#60
Phase 1, #313). CM6 hover and completion info upgrade in place with the
connected ClickHouse server's own
system.functionsmetadata (signature, one-line summary,sinceversion badge,Alias ofnotice) and expose a keyboard-activatable Open reference button; F1 in the editor opens the same entry for the symbol at the caret (documented in the shortcuts dialog). A persistent, non-modal right-side documentation pane (src/ui/doc-pane.ts, resizable,role="complementary", Escape/close with focus restore) renders the full structured entry: description, arguments/parameters, returned value, categories, deterministic/higher-order badges, and copyable examples highlighted with the shared ClickHouse CM6 dialect (extracted tosrc/editor/ch-lang.ts). Availability is capability-gated, not version-gated: the actualsystem.functionscolumns are probed once per connection (works on pre-26.6 servers; missing optional columns degrade field-by-field; denied or absent sources degrade silently, cached per connection), lookups are deduplicated, keyedkind:name, and connection-generation-safe across reconnects, and no documentation SQL ever runs on the keystroke path. The shared non-modal drawer chrome behind the pane was extracted from the cell-detail/rows-viewer scaffold (src/ui/drawer.ts— the shared Drawer primitive deferred to #60), and the editor layer is now an enforced leaf (build/check-boundaries.mjs:src/editormust not importsrc/ui; UI actions are injected app callbacks). - Line/area/bar/hbar charts over a time-role X column now draw a genuine
Chart.js
timescale (#309, follow-up to #310's category-axis mitigation).chartJsConfig(src/core/chart-data.ts) switches the category axis totype: 'time'and each dataset's points to{x, y}(epoch inyand value inxforhbar, whose category axis isY; epoch inxfor every other cartesian type) whenever the X column's role is'time'and every displayed category parses as a ClickHouseDate/DateTime/DateTime64string (a new purechartTimeValue(), reading the literal wall-clock digits with no timezone conversion — same "show it exactly as written" contract as the existingchartLabel(), with a calendar round-trip check so a date-shaped-but-invalid value like month13falls back rather than theDateconstructor silently rolling it over); any category that fails to parse, or an empty result, falls the whole axis back to the existing category scale. Chart.js then places ticks on natural time boundaries and points/bars at their real elapsed time instead of one evenly-spaced category per row, so gaps in irregularly-sampled data show as gaps rather than being compressed away. Pie and non-time-role charts are unaffected. Fifth bundled runtime dependency (CLAUDE.md rule 4):chartjs-adapter-date-fns(+ itsdate-fnspeer), imported for its registration side effect next tochart.js/autoinmain.ts— measured +11.6 KiB gzip / +9.7 KiB brotli to the artifact (date-fns's tree-shaken surface across every add/difference/startOf/endOf unit the adapter imports; larger than the issue's ~3 KB estimate, still a low single-digit percent of the bundle and no new network requests).
- Editable Dashboard and Workbench are now two editors over one committed
workspace, with consistent exports (#341). Previously the standalone
Dashboard persisted layout/order/resize/delete edits with a fire-and-forget
workspace.commit()that only bumped a route-local revision counter — it never projected the committed aggregate back throughapp.applyCommittedWorkspace, soapp.state.dashboard(which both exports read) went stale, rapid edits could start overlapping commits outside the saved-query write queue, and an older commit resolving after a newer one could clobber tile order/placement/style. All workspace writes now go through one app-level primitive,app.mutateWorkspace(transform): a queued op (the sameserializeWritechain saved-query mutations use) that reads the latest committed aggregate viaworkspace.loadCurrent()at dequeue time, applies the caller's transform to it, and commits — because a queue around independently pre-built full snapshots still loses updates, every File-menu mutation (rename, import queries/dashboard, replace, new) builds its candidate inside the transform from that dequeue-time baseline (the conflict dialog's snapshot is UX-only, and the collected decisions are revalidated at dequeue time: a query-id conflict minted while the import waited in the queue auto-resolves when canonically identical, and aborts the import with a "workspace changed" toast when the content differs — never a silent skip; the "Imported N" toast now reports what the plan actually imported, not the bundle size), and every editable Dashboard command (move-tile,update-placement,change-layout,remove-tile) is queued as a command descriptor re-applied to committed truth when its turn arrives — never as a document snapshot derived from a prior command's still-uncommitted optimistic doc, so a rejected command can't be smuggled into a later command's successful commit. An optimistic preview stays instant; after every resolution (success,ok:false, no-longer-applies abort, or a storage rejection) the still-pending descriptors are rebased onto committed truth and re-published, so a failed command's effect disappears everywhere, revisions stay strictly monotonic, the diagnostic is toasted, and the shared queue is never wedged. A failed or no-longer-applies command rolls back to the dequeue-time committed truth its queued op observed (never a stale route-local cache — so a tile a concurrent producer removed disappears from the render, too), and a rollback that must restore a removed tile rebuilds the Dashboard route from committed truth (#350), since the viewer session'ssyncDocumentcan reorder and drop tiles but never reinstate one. Dashboard export and Workbench workspace export both nowflushWorkspaceWrites()(await pending writes) then build from the latest committedStoredWorkspaceV1(workspace.loadCurrent()), falling back toapp.stateonly when no aggregate is persisted or the read rejects — so the Dashboard document and every shared saved-query resource are canonically identical across both bundles, and export/import preserves exact tile order, shape, style, filters, and membership. Same-tab writes are strictly serialized here; cross-tab optimistic-concurrency (revision CAS) is a scoped follow-up (#343), consistent with #341's cross-tab minimum contract (reload consistency). - Dashboard KPI bands now render as one horizontal wrapping row in flow
layouts (#331). The flow renderer emitted each consecutive-KPI band member
as
.dash-kpi-member, but thedisplay:contentsflattening rule targeted a never-emitted.dash-kpi-sourceclass, so members stayed block-level and the KPI cards stacked vertically instead of sharing the band'sflex-wrapstream. The class contract is unified on.dash-kpi-memberacross renderer, CSS, and tests; Report / 2-column / 3-column bands are now a single horizontal card stream in canonical tile order. Grid Tiles (#321) and frameless (#316) KPI behavior are unchanged. - Dashboard File menu now shares the Workbench File-menu primitive (#331).
A new data-driven
src/ui/menu.ts(openMenu) owns the shared dropdown structure and interaction grammar — icon/label/metadata columns, section headings, separators, overlay/outside-click dismiss, Escape + focus-restore, ArrowUp/Down roving focus,aria-haspopup/aria-expanded, and anchored placement — and both the Workbench (file-menu.ts) and Dashboard (buildDashboardFileMenu) menus are rebuilt on it, ending the duplicated builders. The Dashboard File trigger now uses the same downward-chevron treatment as Workbench (no more right-arrow that misread as navigation) and its menu gains EXPORT/IMPORT/OPEN section headings, icons, and.jsonmetadata; read-only Dashboards expose Export only at this point (superseded by #347, below). Actions and permission rules are unchanged. - Read-only Dashboard views (detached current-workspace and one-time
session-bundle) no longer render a File menu at all (#347). The prior
read-only behavior above (#331) still showed an Export button, but
exportDashboardActionresolves the latest committed primary workspace viaapp.workspace.loadCurrent()— in a read-only route that primary workspace can be unrelated to the Dashboard actually on screen, so clicking Export could silently download the wrong (and potentially private) Dashboard and its queries.buildDashboardFileMenuno longer takes areadOnlyflag; the caller now omits the button/menu from the DOM entirely — never disabled or visually hidden — when the resolved route is read-only, keeping keyboard tab order and the accessibility tree free of an inoperable control. Editable current-workspace Dashboards are unaffected and keep the full Export/Import/Open-for-viewing menu. - Dashboard tile content stays inside the tile body, above the footer
(#331).
.dash-tile-bodygainedoverflow:hidden, and the tall-panel containment contract (flex:1 1 auto; min-width/min-height:0; max-height:100%; overflow:auto) now covers Markdown/text (.md-view) and the diagnostic.panel-with-notewrapper alongside tables and logs, so long content scrolls inside the body instead of painting over the footer or neighbouring tiles (both flow and Grid Tiles engines, which share the tile shell). A metaless panel (e.g. a Text tile that never runs a query) now hides its footer rather than reserving an empty footer line, reasserted before the unchanged-rows repaint short-circuit so a sibling tile's refresh can't leave it stale.
0.6.0 - 2026-07-18
grafana-grid@1— a rowless Grafana-style tile-grid Dashboard layout (#291, building on #280). A second layout engine next to the normativeflow@1: a responsive 12-column tile grid with deterministic packing — per-tile{span: 1–12, height: 1–16 row units}(px = 32 + 88×units; units 1/2/3 ≈ the flow tiers, unit 16 = 1440 px ≈ 5× flow's tallest; the legacycompact|medium|largestrings stay readable and upgrade to 1/2/3 on the next edit) persisted inlayout.items, vertical position derived purely from the canonicaldashboard.tiles[]order, and a container-width column clamp (12/6/4/2 at ≥1160/≥720/≥470 px of content-box width) that never mutates persisted spans. No rows, no folding (owner decision). The engine registers lazily in the layout registry (schemas/dashboard-layout-grafana-grid-v1. schema.json+ generated types/validator; thefallbackslot stays pinned toflow@1), and every grid mutation deterministically regenerates a validflow@1fallback (grid→flow span 1–4→1, 5–8→2, 9–12→3). The edit-mode layout select gains a Grafana grid option: switching from flow seeds spans from the current flow placements (1→4, 2→6, 3→12) and snapshots the flow layout as the fallback; switching back restores it. Workbench-only (edit-mode) placement editing per the mock: whole-card drag-reorder, corner-drag resize (span snaps to columns, height snaps per row unit across all 16 stops; the tile is pinned to its column during the drag so the persisted placement always matches the pointer), and tile delete — every change propagates immediately (no Save/Undo, owner decision). KPI tiles place like any other tile (no KPI band). Filters, panel/KPI rendering, and view-mode read-only rules (#306/ADR-0003) are reused unchanged. Includes a real-browser e2e harness (tests/e2e/dashboard-grid.*) covering packing, row-unit heights, the responsive clamp, 360 px overflow, resize, and hover chrome.- Direct + full-screen Dashboard viewing with a one-time cross-tab handoff
(#288, Dashboard v1 Phase 6 — the final phase of #280; also lands #302). Two
explicit viewing modes on the standalone
/dashboardroute (ADR-0003): an edit mode opened from the new Workbench-headerDashboard →control (bookmarkable?ws=&dash=current-workspace route that verifies BOTH the workspace and dashboard ids — showing Dashboard unavailable rather than silently opening a different one — and shares the IndexedDB workspace store so reorder/relayout persist), and a read-only view mode opened from the Dashboard page's own File → "Open for viewing…" that snapshots the current dashboard into a detached copy in a new tab. The cross-tab state handoff uses a one-time IndexedDB token (unguessable 256-bit token; the validated bundle is written before the tab opens, atomically consumed exactly once, and the token stripped from the URL viahistory.replaceState) —sessionStoragealone is insufficient. The consumed handoff materializes into a persistent detached-views store under its own id, so a view tab survives relogin/reload while staying detached from later Workbench edits. The read-only viewer path builds no Workbench/editor modules (boundary-tested). Closes #153. - Portable open/import/export + a transactional import planner (#287, Dashboard
v1 Phase 5). The File menu's ambiguous
Appendis gone, replaced by resource-oriented Import queries / Import Dashboard / Replace workspace and Export Dashboard / Export workspace operations, all writing the one canonicalaltinity-sql-browser/portable-bundle(PortableBundleV1) interchange format through the deterministic canonical encoder. Export never mutates workspace identity or Dashboard revision; Export Dashboard emits a dependency closure (the Dashboard plus exactly its referenced queries), Export workspace emits the whole catalog plus the zero-or-one Dashboard. Import runs a transactional planner (src/workspace/import-planner.ts): parse → schema validation → select Dashboard → dependency closure → conflict detection → decisions → complete ID remapping → central reference rewrite → candidate workspace → whole-workspace validation → atomic commit, with conflict actions use-existing (automatic only on canonical equality), copy (fresh id + full reference rewrite), replace, and skip (skipping a required Dashboard dependency invalidates the Dashboard import rather than silently dropping a tile). A multi-Dashboard bundle asks which Dashboard to import. Legacy Library v1/v2 files remain importable (normalized to an in-memory bundle withdashboards: []); no new Library-only JSON is written.
- Dashboard operations moved out of the Workbench File menu (#302). The
Workbench File menu now owns workspace + query-collection operations only (New
workspace / Import queries / Replace workspace / Export workspace / Download
Markdown+SQL / variable history). Dashboard navigation moved to a
Dashboard →control next to the workspace name (shown only when a Dashboard exists; opens the standalone route in a new tab), and Import Dashboard / Export Dashboard moved to the standalone Dashboard page's own resource-scoped File menu (which also hosts the new Open for viewing…). Dashboard import still runs the transactional planner and commits atomically; portable-bundle, schema, and persistence semantics are unchanged. - The
StoredWorkspaceV1aggregate is now the single source of truth for the saved-query collection (#287). All query CRUD (create/edit/rename/delete/star) commits the whole workspace through the atomicWorkspaceRepository(validate-before-publish per #280) instead of the flatasb:savedlocalStorage key, which is retired (read once only as the legacy-migration source); boot loads and projects the aggregate. Saved-query writes are serialized so two rapid concurrent edits can't interleave and resurrect a just-deleted query. The external-bundle trust preflight and durable cross-tab transport are deferred to Phase 6 (#288); every Phase-5 file operation only commits to the workspace and never executes queries.
- The Workbench favorite star now drives Dashboard tile membership (part of
#299, post-#287 aggregate). Since
dashboard.tiles[]became the canonical Dashboard membership (#280/#287), starring a query only flippedspec.favoriteand never added a tile, so favoriting/unfavoriting in the Workbench had no effect on the Dashboard.toggleFavoritenow reflects the flip onto tile membership in the same atomic commit: a favorited panel-role query gains a tile (mirroring the legacy migration's role gate — a filter/setup-role favorite stays favorite-only), and unfavoriting removes every tile referencing the query and scrubs those tile ids from filter targets.spec.favoritestays the star's visual state (the documented dual-write retirement is deferred). - A corrupt-but-present workspace aggregate is surfaced on boot instead of
silently swallowed (#300).
WorkspaceRepositorygainsloadCurrentResult(), which distinguishes "no aggregate yet" (empty) from "a record is stored but won't decode/validate" (corrupt) — previously both collapsed tonull, so a corrupt record let boot continue on the legacy projection and the next CRUD commit orphaned it with no user-visible error. Boot now shows a toast ("Saved workspace could not be read…") with a Reset workspace action that clears the unreadable record and rebuilds a fresh aggregate from the current local state.flashToastgained an optional non-auto-dismissing action button. - Dashboard filter values persist across a reload again (#303, regression from
the #280 dashboard rewrite). The isolated viewer session initialized every
filter purely from
def.defaultValue/defaultActiveand persisted nothing, so a committed filter value lived only in memory and reset on reload (the Workbench var-strip was unaffected). Filter runtime value/active now persist to an isolated per-dashboard key (asb:dashFilters,dashboardId → filterId → {value,active}) — deliberately separate from the Workbench'sasb:varValues/asb:filterActive— seeded back on load. Persistence stays a puredashboard/modelstore behind the existingloadJSON/saveJSONseam; opening a dashboard does not persist its defaults (only genuine committed changes write). - Dashboard filter strips no longer wrap, the visible "Clear all" control and
"N active" count are both removed, and the layout switcher moved to the
header as a compact select (#294 + a same-PR 2026-07-18 owner follow-up —
together reverse the visible clear-all/count UX decisions made in #286/#293).
.dash-toolbarand the filter field region (.dash-filter-host, which IS now the horizontally-scrolling viewport — no separate.filter-strip-scrollwrapper) stayflex-wrap: nowrapat every viewport width, not just the ≤768px mobile breakpoint from #248 — several{name:Type}params at desktop width used to wrap the toolbar onto extra rows, permanently shrinking the dashboard's data area. Both the Clear all button (filterClearAllButton,.dash-filter-clear-all) and the "N active" status (filterActiveCount,.dash-filter-count/.dash-filter-count-host) are deleted outright —DashboardViewerSession.clearAllFilters()/activeFilterCountstay tested application-level operations/state with no UI trigger or display. The toolbar itself is now hidden whenever there are no filters, at every width (previously mobile-only), since it holds nothing else. The flow preset switcher is now a compact<select class="result-panel-select">— matching the Workbench's panel-type picker convention (renderPanelTypePickerinpanels.ts) — in the header's top row right after the tile-count chip, replacing the four-button.dash-segsegmented control that used to live in the filter toolbar; a select needs far less room, so the whole toolbar width goes to filters. The required/optional{name:Type}filter-name affordance (shared by the Workbench var-strip and the Dashboard filter bar) is now bold for a required name instead of a leading*glyph. Combobox popovers, relative-time previews, and filter execution/activation/debounce/recents/ validation are unchanged. The "hide native scrollbar, allow horizontal auto-scroll" viewport contract is one shared rule read by both.var-stripand.dash-filter-host(previously copy-pasted per surface — review follow-up), and the scroll viewport carries enough vertical padding that a focused field's box-shadow ring is never clipped by itsoverflow-y: hidden. New Playwright coverage (dashboard-mobile.spec.js,workbench-var-strip.spec.js) pins the never-wrap/scroll contract, the focus-ring padding, the bold/muted required-vs-optional styling, and the relocated layout select, for both surfaces in a real browser (invisible to the happy-dom unit suite). Dead-CSS cleanup from an audit prompted by the same change:.dash-skip(styled but never constructed bydashboard.tssince #286 flipped Dashboard membership ontodashboard.tiles[]— there is no "N not shown" concept left to display) is deleted fromsrc/styles.cssand thedashboard-mobile.html/.spec.jsfixture. A second dead class,.dash-grid.is-wide, turned out to guard a real, still-intended "Full width" preset behavior that #286'sflow@1rewrite never re-wired (is-reportkept its toggle,is-widelost its) — filed as #298 rather than deleted or silently fixed here, since restoring it is a distinct rendering fix that needs its own >1560px-viewport regression test. - Line/area chart X-axis tick labels no longer rotate into an unreadable
wall of timestamps at a few hundred rows. Line/area charts still draw a
Chart.js
categoryscale (one tick per distinct row — no time scale yet), sochartJsConfig(src/core/chart-data.ts) now forces the category axis's ticks toautoSkip: true, maxRotation: 0, minRotation: 0for those two chart types, letting Chart.js drop enough labels to stay horizontal instead of rotating every one of them. Bar/hbar/pie category axes are unchanged. A real Chart.js time scale (natural tick boundaries, gap-aware point placement) is filed as a follow-up: #309.
-
Dashboard v1 contracts, codecs, canonical encoding, and resource limits (#283, phase 1 of #280). New canonical JSON Schemas ship through the existing manifest pipeline with generated types and Ajv-compiled validators:
stored-workspace-v1,dashboard-v1(withDashboardTileV1,DashboardFilterDefinitionV1,DashboardLayoutDocumentV1, and theDashboardLayoutFallbackV1 = flow@1fallback),dashboard-layout-flow-v1(the normativeflow@1contract with the closedFlowTilePlacementV1— span 1|2|3, height compact|medium|large — and the presets full-width|report|columns-2|columns-3), andportable-bundle-v1(queries+dashboards, both required even when empty).query-spec-v1gained the additiveQueryDashboardPresentationV1(role/defaultVariant/ variants/sizeHints) plusQueryPresentationPatchV1(RFC 7396 merge-patch of the panel spec) — a revision, not a new Spec version; the schema-driven Spec editor autocomplete picks the new fields up automatically. Puresrc/additions: one canonical deterministic encoder (dashboard/model/canonical-json.ts, shared by export/persistence/hashing/equality), a deterministic numeric-aware diagnostic sorter,PORTABLE_LIMITSenforcement (schema bounds + codec UTF-8-byte/depth guards + runtime re-checks), and whole-workspace cross-resource semantic validation. No persistence, no UI, no import planner yet (later phases of #280). -
Atomic
StoredWorkspaceV1persistence and one-shot legacy migration (#284, phase 2 of #280). New puresrc/workspace/modules: aWorkspaceRepository(loadCurrent/commit/clearCurrent) that validates the complete candidate through phase-1 whole-workspace validation before writing and then atomically replaces the aggregate in one transaction — a failed write leaves the previously stored workspace intact, never increments Dashboard revision, and never produces a partially-mixed workspace (multi-tab policy: last successful commit wins, atomic replacement, not compare-and-swap). Persistence is IndexedDB (chosen over a single localStorage key: the 10 MiBmaxDecodedJsonBytesaggregate exceeds the ~5 MB localStorage quota, and phase 6 needs IndexedDB anyway) behind an injectedWorkspaceStoreseam (env.indexedDB, mirroring the crypto/storage seams) so the repository unit-tests against a plain in-memory fake. The one-shot legacy migration builds one candidateStoredWorkspaceV1from the flatasb:*keys — the initial Dashboard fromspec.favorite(panel-role favorites become tiles in catalog order),asb:dashLayout/asb:dashColsconverted to a validflow@1layout — validates the whole candidate, and persists it atomically; it runs only when no aggregate record exists and never deletes or modifies legacy keys. Repository + operation semantics only; no authoring commands, viewer, or import UI yet (later phases of #280).spec.favoritedual-write removal path. During phases 2-4spec.favoritestays the membership source the existing favorites-driven Dashboard render reads, whiledashboard.tilesbecomes the canonical membership going forward; phase 3's star command dual-writes both. Membership reads flip todashboard.tileswhen phase 4'sDashboardViewerSession+flow@1viewer replacesrc/ui/dashboard.ts'ssavedQueries.filter(queryFavorite)render; thespec.favoritedual-write is then deleted in the v1 Dashboard GA release (the release closing #280), after phase 5 lands — the schema keepsfavoriteonly as an ignored legacy-compat field readable by old migrations.
-
Dashboard authoring domain: atomic commands, presentation resolution, and workspace-wide validation (#285, phase 3 of #280). New pure/typed modules, each 100%-covered, keeping the direction model/layouts ← application ← UI:
dashboard/model/json-merge-patch.ts— an in-house RFC 7396 JSON Merge Patch (no new dependency): objects merge recursively, anullmember deletes, arrays and non-objects replace wholesale, and the result shares no structure with its inputs.dashboard/model/presentation-resolver.ts— the ONE canonical presentation resolver (base panel → selected/defaultVariantvariant patch → tile override → final validation), shared by Dashboard viewing, saved-query mutation validation, and tests. Enforces the #280 rules: a missing selected variant name fails (no silent fallback), neither a variant nor an override may changepanel.cfg.type, deleting a required field fails final validation, and result-column role validation runs only when result metadata is available.dashboard/layouts/flow-layout.ts— the minimalflow@1layout plugin the authoring domain needs: placement validation (closed span/height contract), document normalization (orphan-placement pruning), size-hint-derived initial placement, andresolveActiveLayoutPlugin(flow@1, or an unsupported primary with a valid flow@1 fallback, else a load failure).dashboard/application/dashboard-commands.ts+dashboard-query-resolver.ts— fallible, atomic typed commands for every membership/placement/layout change (add-query,add-query-instance,remove-tile,move-tile,update-tile,update-placement,change-layout): clone the draft → apply to an isolated candidate → return diagnostics or the candidate without mutating the input. The unused session wrapper was removed before gaining a production caller; future non-DOM authoring must be built against the sharedmutateWorkspacepipeline.dashboard/application/saved-query-mutation.ts—planSavedQueryMutationconstructs and validates a complete candidate workspace for every saved-query mutation (delete or replace), rejecting an invalidating one unless the caller supplies an atomic repair (remove affected tiles/filters, switch to another variant, or remap references), then commits mutation + repair as one candidate. A newcheck:archrule keepsdashboard/applicationoff the Dashboard UI. Live Workbench wiring consumes these commands through the shared workspace mutation path.
-
Dashboard read-only viewer runtime + normative
flow@1layout (#286, phase 4 of #280). New pure/application modules, gated at the per-file thresholds, all constructible and unit-tested with plain fakes — no Workbench, no fullApp, noAppState, no editors:dashboard/application/dashboard-viewer-session.ts— the standaloneDashboardViewerSession: it takes an immutableDashboardDocumentV1snapshot plus the workspace queries and runs the Dashboard end-to-end, reading membership fromdashboard.tiles[](notspec.favorite) and resolving every tile through the ONE sharedpresentation-resolver(resolveDashboardPresentations/resolvePresentation— no copy). It owns only runtime state — filter values/activation, per-tile results/errors/ progress, the resolved flow layout — published through onestatesignal a renderer subscribes to;start/refresh/refreshTile/setFilter/cancelTile/destroyplusclearFilter/clearAllFilters, with bounded concurrency, per-tileAbortControllercancellation, stale-wave generation guards, anddestroy()teardown. It depends only on narrow injected seams (a query executor, a token-preflight connection, an optional layout registry) declared locally, so it imports nothing fromsrc/application,src/net,src/state.ts,src/ui, orsrc/editor.- #235 resolved inside the execution planner: panels whose declared params
cannot be affected by any filter source query run in PARALLEL with the
filter wave; panels a source-backed filter targets wait for the wave and see
the correct blanked/active values on the first pass. The overlap is computed
from the explicit
DashboardFilterDefinitionV1.parameter/targetscontract. - Filter usability semantics (absorbing #188's kept scope): per-filter
clear that deactivates without discarding the last value (reactivation
restores it, one affected-panel wave), a coalesced clear-all resetting every
filter to its
defaultActive/defaultValuein ONE wave, and anactiveFilterCountcounting active filter DEFINITIONS. (The per-filter "required/invalid" blocking badge was dropped as noise by owner decision — an unfilled required filter just leaves its target tiles unfilled.) dashboard/layouts/flow-layout.tsextended with the normativeflow@1render math (pure, 100%):presetColumns,effectiveSpan(min(storedSpan ?? 1, activeColumnCount)— preset changes never rewrite stored spans),resolvePlacement(defaults span 1 / medium), andcomputeFlowLayout— deterministic row-major packing (no overlaps), maximal-consecutive-KPI-run band grouping preserving #240, mobile one-column normalization that never mutates persistence, and the order equivalencedashboard.tiles[] = DOM = keyboard = visual row-major = print/export.dashboard/layouts/layout-registry.ts— a compile-time-registered, lazy-loadable layout registry (DashboardLayoutRegistration{id, versions, load}), never arbitrary remote plugins, with the fallback contract: a primary engine that cannot load falls back to a validflow@1fallback, and an unsupported primary without one fails closed.- Accessible authoring (
moveTileEarlier/moveTileLater/setTileSpan/setTileHeight) is driven by the phase-3move-tile/update-placementcommands; pointer drag (#153's reorder half) is an equivalent alternative, never the only mechanism. - A strengthened
check:archrule (plus a unitdashboard-boundariestest) proves the Dashboard model/application layers — including the viewer session — import no Workbench UI,App,AppState, editor,src/application, orsrc/netmodule. - Live read-flip.
src/ui/dashboard.tsis rewritten to render from aDashboardViewerSessionbound to the persistedStoredWorkspaceV1(app.loadDashboardWorkspace()→ the phase-2WorkspaceRepository, running the one-shot legacy migration when no aggregate exists), reading membership fromdashboard.tiles[]— no longersavedQueries.filter(queryFavorite). The DOM reconciles from the session'sstatesignal:flow@1rows/columns, KPI bands (viarenderKpiCards, preserving #240), per-tile loading/unfilled/error/ready with chart-paint-once, mobile one-column normalization at the 768px breakpoint, and the empty state. A migrated Dashboard's implicit{name:Type}params are surfaced as runtime-only filter definitions so the filter bar is not lost. Thespec.favoritedual-WRITE stays until GA (the Workbench star action); only the READ is flipped. - Rich filter bar. The Dashboard filter bar is the SHARED
buildFilterBar— the same rich field family the Workbench var-strip and detached view use (relative-time presets, recents, enum + curated comboboxes) — driven over the viewer's filter model: a draft value/active bag the bar mutates,session.getFilterFieldfor live #170 validation, andsession.applyFilteron commit (which owns activation). Recents flow through the shim from the real app; the viewer never imports globalAppState(check-boundaries keeps the phase-4 forbidden list intact). Toolbar affordances stay: a coalesced clear-all (one wave) and an "N active" count. (The per-filter blocking badge was dropped as noise by owner decision.) - Tile reordering is pointer DRAG ONLY (owner override, final #286 scope).
A drop persists the new
dashboard.tiles[]order through themove-tilecommand. Note: #280's accessibility section says drag should not be the only reorder mechanism — this is a deliberate product-owner override; the per-tile keyboard Move controls were removed. The in-tile span/height buttons were also removed (span/height are tuned in the Spec editor); the underlyingupdate-placement/setTileSpan/setTileHeightauthoring commands stay in the application layer. The per-filter "×" clear affordance was removed too. This closes #235 (filter wave / panel parallelism) and the reorder half of #153 (open-in-window arrives in Phase 6), and dissolves #188 into the viewer's filter contract. - Fix (post-review): the
.dash-gridcontainer kept its old-modelgrid-template-columns, so it laid the new flow.dash-rowwrappers out in columns — full-width showed multiple rows side by side and 3-column showed the rows multiplied across the page. The container is now a vertical flex stack; each.dash-rowowns its own column count. (Invisible to the unit suite because happy-dom does not compute CSS layout; verified in real Chromium.)
- The app.ts → services refactor is complete (#276, Phase 5). The
temporary
Appdelegates from Phases 2–4 are deleted — consumers readapp.conn/app.catalog/app.params/app.queryDoc/app.prefsdirectly through narrow interfaces (app.saveVarRecentsurvives as the one documented exception); the workbench DOM + effects moved intoui/workbench/workbench-shell.tsbehind a narrow deps bag, makingrenderAppa thin composition call; the dashboard shell is typed against a narrowDashboardApp; the per-tab ClickHousesession_idhelpers got their final home (application/ch-session-params.ts, one shared implementation); the boundary guard now also forbids either shell from importingsrc/ui/app.ts. Behavior fix: signing out now tears the session down — an in-flight run is aborted and server-KILLed, exports and lineage fetches cancel, and the schema/reference caches invalidate before the login screen renders (previously stale work could land after sign-out and caches survived account switches).docs/ARCHITECTURE.mdrewritten to describe the actual modular-monolith shape (it still documented the pre-refactor god object as the design). - Phase 4 of the app.ts → services refactor complete (#276): five more
focused modules under
src/application/, all constructible withoutApp/AppState/DOM, behavior byte-identical.WorkbenchParameterSession(app.params) owns the{name:Type}prepare/gate/hardening/recents policy (the var strip stays a DOM view calling it);ExportService(app.exports) owns direct + script export behind an injectableExportSink, consolidating the last duplicated cancellation-state copies (its 40 test scenarios moved to an in-memory-sink spec);QueryDocumentSession(app.queryDoc) owns the Spec evaluation/document lifecycle andSavedQueryService(app.saved) the create/commit/history/ share persistence (typed results; the shell keeps the exact messages and post-commit repaint cascade);SchemaGraphSession(app.graph) owns the lineage operation lifecycle with its stale-request guards, the abort state now session-private;AppPreferences(app.prefs) is the typed preference-persistence surface (save+toggleTheme— deliberately no speculative per-key setters).app.tsis down to 1,761 lines of shell wiring from the original 2,964. - Server metadata and reference lifecycle extracted into
SchemaCatalogService(#276 Phase 4A). Version probe, schema tree loading, lazy column loading, SQL reference/completions assembly, and the entity-documentation cache now live insrc/application/schema-catalog-service.ts(app.catalog), constructible withoutApp/AppState/DOM; the connection status chip and auth banner stay UI, driven by the same signals.app.refData/app.completionsremain writable through forwarding accessors (the CM6 harness mutates them directly), and the service gainsinvalidate()for the Phase-5 connection lifecycle wiring. Behavior byte-identical. - Dashboard tile/filter runtime extracted into a route-scoped
DashboardSession(#276 Phase 3b). Wave generations, per-slot cancellation, the 6-way tile pool, filter waves/merging, and the retry cascade now live insrc/ui/dashboard/dashboard-session.ts— constructible without theAppobject, fed an explicitDashboardRuntimeInput(built by the shell from the favorites list, so a stored dashboard document can replace that source later), with every DOM write behind injected shell hooks.renderDashboard(app)keeps its signature as the integration entry (the existing DOM-driven dashboard suite passed unmodified).destroy()aborts all in-flight tile/KPI/filter work, tears down chart instances, disposes the filter bar, and turns later entry points into no-ops — closing the orphaned-debounce-timer gap (buildFilterBarnow returns{ el, dispose }; both the dashboard and the detached Data view dispose the previous bar on re-render). A stale-generation guard on streamed progress keeps a superseded request's last buffered chunk from touching the newer wave's live label. Tile supersede remains client-abort only (no serverKILL) by design. Behavior byte-identical. - Workbench run lifecycle extracted into a route-scoped
WorkbenchSession(#276 Phase 3a).run/runScript/runEntry/cancelorchestration now lives insrc/ui/workbench/workbench-session.ts; the run bookkeeping fields (runT0/runQueryId/runTick) and the in-flightAbortControllerare private session state (theRunStatecast andAppState.abortControllerare gone — issue rule 5: cancellation belongs to its owner). The session is the sole production writer of therunningsignal; the three run-coupled reactive effects register throughsession.attachShell(...)with captured disposers (idempotent on re-render — also fixes a latent double-registration on the connect → re-render path), anddestroy()releases effects, the elapsed ticker, and any in-flight operation (unit-proven; no production caller until Phase 5's route shells). The architecture guard now carries a rule list: route sessions (ui/workbench↔ui/dashboard) must not import each other, and the dashboard route must not import the editor. Behavior byte-identical. - Authentication and connection lifecycle extracted into
ConnectionSession(#276 Phase 2). OAuth PKCE login/refresh, Basic-auth probing, IdP config resolution, identity, sign-in/out, and the cross-tab dashboard auth handoff now live insrc/application/connection-session.ts— constructible withoutApp/AppState/DOM, with rendering inverted through an injectedonAuthLostshell callback (the session never renders or toasts). The app exposes it asapp.conn;app.chCtxremains the same single live ClickHouse context object (now session-owned). The scalar auth fields (token/refreshToken/authMode/chAuth/basicUserClaim/idpId) left theAppcontract; view/bootstrap-consumed members stay as one-line delegates slated for removal in the issue's Phase 5. Behavior, storage keys, handoff message pinning, and error strings are byte-identical. - Query execution extracted into an application service (#276 Phases 0–1,
the first step of the app.ts → services refactor). The shared
request/stream/normalize core (
runReadInto) and the multiquery-script transport loop (per-statement retry/classification, stop-on-first-failure, per-attemptquery_idpublication for Cancel'sKILL QUERY) now live insrc/application/query-execution-service.ts— constructible withoutApp/AppState/DOM, with every side effect (ch-client, clock, uid, timer) injected.app.runReadIntois deleted; the workbenchrun()/runScript(), dashboard tiles, and the detached Data view all execute throughapp.exec.executeRead/executeScript, andcancel()uses the statelessapp.exec.kill(queryId). Behavior, wire format, retry rules, and error messages are byte-identical. A new architecture guard (build/check-boundaries.mjs, wired intopretestascheck:arch) enforces the day-1 boundary rule:src/application/**must not importsrc/ui/**orsrc/editor/**. Type homes tightened along the way:ResultSortmoved tocore/sort.tsandScriptEntrytocore/script-result.ts(old import paths re-exported), and thefilterExecution/panelExecutionparams bags are now the strict wire shape (Record<string, string | number>), deleting the narrowing casts at every execution call site.
- Bundle-size report on every PR (#275).
npm run size-reportbuilds the production artifact once (through the samebuildArtifactthe release uses, so the measured bytes are byte-for-byte the shippeddist/sql.html) and emits a machine-readablebundle-size-report.json, a human-readablebundle-size-report.md, and the rawesbuild-meta.json. It records raw/gzip/ Brotli sizes for the artifact, JS bundle, and minified CSS; attributes JS output bytes to input modules and npm packages (hand-writtensrc/**, generatedsrc/generated/**, and each external package reported separately); lists the top-30 modules and entry-point/chunk totals; and, given a base report (--base), appends absolute + percentage deltas. A new CIsizejob produces the report on every PR — with best-effort deltas vs. the PR base — and uploads it as an artifact. Reporting only: no bundle-size budget fails the build yet (thresholds are a follow-up once baseline variance is known). Pure attribution/ formatting lives inbuild/size-report-lib.mjs, unit-tested intests/unit/size-report.test.js. - Helm chart. A chart at
helm/altinity-sql-browser/deploys the nginx image to Kubernetes (Deployment + ClusterIP Service + config ConfigMap + optional Ingress/HPA), non-root, config via.Values.config→/sql/config.json, CSP origins via.Values.connectSrc.release.ymlpackages and pushes it tooci://ghcr.io/altinity/altinity-sql-browser/helmonv*tags (altinity-mcp parity). For Altinity edge-proxy environments,service.annotationsexpose a host with no ingress/cert/DNS work (deploy/helm/values-demo.yamltargetssql.demo.altinity.cloud). - Production container + GHCR image. The Docker image is now a static
nginx server for the single-file SPA (replacing the containerized Python
runner): it serves
/sql,/sql/dashboard, a mountedconfig.jsonat/sql/config.json, and/healthz, runs non-root on port 8080, and carries the same security headers/CSP as the on-cluster deployment (CONNECT_SRCenv fills the CSPconnect-src). A new.github/workflows/docker.ymlpublishes a multi-arch (amd64+arm64) image toghcr.io/altinity/altinity-sql-browser—edgeonmain,X.Y.Z+lateston release tags — so the image ships as part of every release.deploy/config.json.examplenow carries a runnable demo (antalya + github.demo, each indemo:demoand Google-SSO modes) and is baked in as the default served config.deploy/k8s/anddocker-compose.yamlupdated to the nginx model (config via ConfigMap/mount,securityContext,/healthz+/sql/config.jsonprobes).
- ADR-0002 TypeScript migration complete (#267). Every hand-written module
under
src/(106 runtime modules + 6 type-only seam files) and every unit test is strict TypeScript, converted leaf-up in dependency-tier waves with zero behavior change — the tail coveredchart-data,ch-client, the CM6 adapters,explain-graph,results(which now owns the canonicalResultunion),app.ts(declared against theAppcontract, withapp.types.tsreconciled to reality:SchemaFocus.kind,specCompletionSources: DynamicSources, correctedloadIdps/openNodeDetailsignatures), andmain.ts.tests/helpers/fake-app.tssatisfies the fullAppcontract, deleting ~1,080 lines of per-test stub scaffolding across eight specs. Still.jsby design: the two generated artifacts undersrc/generated/, the Playwright e2e specs (outside thetscgate), and two node-tooling unit specs pending an@types/nodedecision — the vitest mixed-tree resolver shim survives only for those two. The build and artifact are unchanged; runtime deps stay four. The conversion surfaced and fixed two real droppedreturns (exportEntry,renderDashboardpromises) and a latent never-exercised completion-ranking branch. - Chart style internals consolidated (post-#258 review). The per-chart-type
style surface (which fields each type owns, their accepted values, and their
defaults) now lives in one
CHART_STYLE_SPECtable that bothnormalizeChartStyleandchartStylePresetread, replacing the parallel field lists that could drift. Chart rendering resolves visible-measure field metadata once per repaint (threaded fromchart-renderthroughchartJsConfigintobuildChartData) instead of three times. No user-facing behavior or schema change.
- Chart aggregation sums the whole fetched result, and the note is honest
(#111, follow-up to #109).
buildChartData()no longer slices the raw rows before grouping — it discovers unique X categories across all fetched rows (first-seen order), retains the firstchartRowCap(type)of them, and aggregates every fetched row whose X falls in a retained category. A row past the old raw-row boundary now still contributes to its category, and a category is no longer dropped merely because its first row arrived late. The result carries typedmeta(totalRows/totalCategories/shownCategories/categoriesTruncated/duplicateCellsSummed/groupKey); a new purechartDataNote(meta)rendersfirst N of M categoriesand/orduplicate X[/series] rows summed in the browser— describing only the browser-side transform, never whether the SQL pre-aggregated. Duplicate detection keys on X (or(X, series)), so a normal multi-series result with repeated X values is not reported as summing, and a duplicate confined to an omitted category never flags the visible chart. The note now renders on every surface — Dashboard and read-only/detached tiles (controls:false) disclose category truncation and duplicate summing as a standalone block above the canvas, instead of silently capping. Chart data is aggregated once per render (chartJsConfigaccepts the precomputed result). - Share-link result view is validated before use (#266). The share-link
bootstrap (
src/main.ts) now narrowsspec.viewagainst theresultViewenum before assigning it — the v2 tagged decode passesspec.viewthrough verbatim, so a crafted link could previously setresultViewto an arbitrary string. Legacyview: "chart"still maps topaneland a Filter-role preview still wins; any other value silently falls back to the default view, matching the Library-activation path (ui/saved-history.ts).
- ADR-0002 phases 1–5: the TypeScript migration slice lands whole (#262,
same PR as phase 0). Phase 1:
build/emit-schema-types.mjs, a hand-rolled deterministic emitter (nojson-schema-to-typescript— see the ADR-0002 addendum), generatessrc/generated/json-schema.types.ts(QuerySpecV1/SavedQueryV2/LibraryV2+ a usablePanelCfgdiscriminated union with aFuturePanelCfgforward-compat member) from the canonical schema manifest, staleness-checked bycheck:schemas;src/schema-contract.types.tspins the semantic invariants atcheck:typestime. Phases 2–5 convert 19 modules to strict.ts— the state model (src/state.ts, typed signals, honest localStorage ingress), the saved-query/panel/dashboard contract spine, the parameter/filter/ execution pipeline, and the dashboard runtime (typed slot lifecycle, tile/KPI hook contracts) — zero behavior change, still-.jsimports typed via local wrapper consts, per-file coverage held. Their unit tests convert to.tsas a second wave (assertions unchanged); vitest gains a mixed-tree resolver shim (Vite only retries.js→.tsfrom TS importers). The build and artifact are unchanged; runtime deps stay four. - ADR-0002 phase 0: TypeScript gate (#262).
tsconfig.json(strict,allowJs,checkJs: false,noEmit,erasableSyntaxOnly);typescriptdevDependency;tsc --noEmitjoinspretest(and therefore CI) alongsidecheck:schemas. Five type-only.types.tsseam-interface files, each co-located next to the runtime module it describes:EditorPort,SpecEditorPort,CodeViewerHandle,CreateAppEnv/BootstrapEnv, and theAppcontroller surface as consumed by render modules. No existing.jsmodule converted; the build and artifact are unchanged. Vitest coverageincludewidens tosrc/**/*.{js,ts}, excluding the type-only files (no executable statements) alongsidesrc/generated/. - ADR-0002 accepted: incremental strict TypeScript, dev-time only
(
docs/ADR-0002-static-typing.md; phase 0 tracked by #262).tsc --noEmitjoins the gate; files convert leaf-up one at a time; esbuild and the artifact are untouched, runtime deps stay four. In the same review, ADR-0001 gained an addendum re-affirming no UI framework at the dashboard milestone and replacing its re-evaluation trigger with three concrete conditions (plugin ecosystem, virtualized large lists, rising invalidation-bug rate). - Bar, Column, Area, and Pie now share type-specific presentation presets
(#258) through the same compact Style selector. Bar/Column add Grouped,
Stacked, Compact, Joined, Minimal, and Data range; Area adds additive
Stacked to its Line-family variants; Pie adds Donut and a genuinely compact
frame. Presets update one type-specific
panel.cfg.styleobject, match that object before claiming a named state, preserve dormant fields and unknown extensions across type switches, and render identically in the workbench and Dashboard. The canonical Spec schema and completion now document the complete type-specific style branches. - Line and Area Style now offers seven complete presentation presets
(#256). Clean, Smooth, Stepped, Points, Zero-based, Minimal, and Sparkline
write the full renderer-independent
panel.cfg.styleobject through the existing compact selector; exact advanced combinations that match none of them remain visible as a disabled Custom selection. Scale, legend, grid, and axes settings join curve and point controls in the canonical Spec schema and completion surface, preserve unknown extensions, and render consistently in the workbench, detached views, and Dashboard. Sparkline removes axes, grid, legend, and normal markers while retaining hover targets, tooltips, data, colors, and responsive sizing. - Chart panels now apply saved
panel.fieldConfigmetadata consistently (#254). Exact result-column overrides merge over defaults to drive legend display names, per-measure tooltip units/decimals and descriptions, and shared-axis formatting when visible measures are compatible. Hidden measures are omitted without rewriting their saved Y selection, with an explicit all-hidden state; missing and invalid measure values now remainnullgeometry instead of becoming zero. The same shared renderer supplies workbench, detached, and Dashboard charts, while the compact chart toolbar remains unchanged and advanced formatting stays in the Spec editor.noValuemetadata remains preserved, but interactive missing-point text is deferred to #257 because Chart.js excludes null/skipped points from normal tooltip selection; configuring onlynoValuedoes not alter valid values' existing localized tooltip formatting. - Line and Area panels now have compact style presets for dense time series (#252). Clean, Smooth, Stepped, and Points controls map to explicit Chart.js curve and point settings, with automatic point visibility based on the final rendered label and dataset counts. Style metadata persists across panel type switches, shares, OAuth handoff, and Library import/export, while unsupported future string values remain lossless and render with safe defaults.
0.5.0 - 2026-07-15
- The dormant
html{zoom}bridge and every runtime/test path that compensated for it are torn out (#148). PR #147 had already set--zoom: 1(native page scale), leaving the whole zoom-correction layer a no-op; this removes it outright:html { zoom: var(--zoom) }, the--zoom/--vp-zoomcustom properties, and the@supports not (zoom: 1)fallback are gone fromsrc/styles.css, along with the viewport-unitcalc(.../var(--vp-zoom))sizing on the fullscreen graph overlay and detached-tab panels (now plain100%/native full-height layout).src/core/zoom-support.js(viewportZoom) andapp.measureViewportZoom/app.applyViewportZoom/app.vpZoomare deleted, as is the detached-view's--vp-zoommirroring onto a child tab.zoomScale()is deleted fromsrc/ui/dom.js;fixedAnchor(rect, opts)drops itsscaleargument (all File/Save/user-menu/combobox/footer callers updated to native coordinates). Splitter (dragValue/startDrag), result-grid column resize (colResizeWidth), schema-detail pane resize, and the cell-detail drawer all drop their scale argument/callback and operate on nativeclientX/clientYdirectly. Chart.js'sunzoomChartEvent/installChartZoomFixpointer-event correction is removed; the chart instantiates directly (its cross-realm detached-document resize fix is unrelated and stays). The synthetictests/e2e/zoom.html/zoom-support.spec.jsharness andtests/unit/zoom-support.test.jsare deleted in favor of behavior-level coverage on the normal app surfaces. No behavior change intended — the app used--zoom: 1already; this only removes the now-dead compensation code.
- The Dashboard now has a compact mobile presentation at the canonical 768px breakpoint (#248). Its sticky header stays on one line with an icon-only back link and Refresh action, an ellipsized Library title, and the theme action; secondary favorite/source/update metadata and the desktop layout selector are hidden. Every saved desktop layout is visually overridden to one normal-height full-width column without changing its persisted preference. Dashboard filters stay in one horizontally scrollable row with fixed-position combobox popovers, while a Dashboard with no filters omits the now-empty mobile toolbar entirely. Widening the viewport restores the saved desktop layout and full controls automatically.
- Favorited saved queries can now act as Dashboard Filter sources (#160).
One explicit read-only query returns exactly one row containing any number of
Array(T),Array(Tuple(value T, label L)), orMap(K,V)helpers. Exact result-column names upgrade matching Dashboard parameters to strict, searchable single-select controls; invalid sources and provider conflicts fall back per field without delaying or removing unrelated panels. Filter requests run and reconcile persisted activation before Panel requests start, with bounded concurrency, cancellation generations, Refresh, and source Retry. The workbench result selector is role-aware, preserves dormant Panel config, and provides a completed-run-only Filter preview without changing shared Dashboard values. examples/query-log-explorer.json— a worked Dashboard Filter sources demo againstsystem.query_logon any cluster: one Filter source per option shape (Array(Tuple(value, label)),Map(String, String), plainArray(T)), plain auto-detected fields alongside them, a KPI panel, four analytical Panels adapted from the Altinity KB's "Handy queries for system.query_log", a Logs panel, and a Text panel explaining the demo.
- Opening a Filter-role saved or shared query now always starts in the
Filter preview (#244, folds in #249). Library-row activation previously
restored whichever result view (Table/JSON/Panel) was already open,
independent of the query's Dashboard role; a Filter-role query with SQL
that can't auto-run (empty/DDL, reachable via an import or legacy
localStorage entry that bypassed SQL-shape validation) fell through to no
view change at all. Separately, the share-link/OAuth-handoff bootstrap path
only ever restored
resultViewfor a queryless Panel link — any SQL-bearing shared Filter query, or a SQL-bearing shared Panel query with a persistedspec.view, always landed on the default Table view. Both call sites now resolve the samerolePreviewView(spec) || queryView(query)precedence (src/core/result-choice.js): a role-owned transient preview wins over a persisted view, which wins over the queryless-Panel/default fallback. The preview stays transient — nospec.view: "filter"is ever persisted — and ordinary reruns after the query is open continue to preserve the user's current Table/JSON/Filter selection. - Review follow-ups on the Dashboard Filter sources work above, found in a
UI/UX pass on #232 before merge: a curated field's clear (×) button now
reports the cleared value (not the stale prior selection) to
varValues/filterActive, and gets anaria-labelnaming the field it clears instead of an anonymous "×" (this is what the e2e suite was actually catching — same bug, both assertions). The clear button is icon-based and positioned inside the field like every other clear affordance, instead of falling into normal flow below the input. The Dashboard's role/Filter diagnostic banners (.dash-config-diagnostic, e.g. "Filter helper … has no current Panel consumer") and the workbench Filter preview's type/diagnostic text now have real styling — both referenced undefined CSS variables and rendered as unstyled body text. The tab-strip/Library "Filter" role badge no longer reads as a second open tab (it shared the bordered.qtabrow with no styling of its own).Enum8/Enum16andLowCardinality(...)columns are now recognized as valid Filter/KPI scalar types (the type parser rejected Enum's quoted member list and never unwrappedLowCardinality). A Filter or KPI query's Table/JSON view no longer shows[object Object]for named-tuple columns serialized as objects. A curated field now gets the same is-invalid/conflict affordance a plain filter field does. A real pointer click on the clear button double-committed (mousedown blurred the input before the click handler ran); fixed with the same commit-before-blurpreventDefaultpatterncombobox.jsalready uses for option commits. A curated field never got theis-optionalCSS class, so it always showed the required-field asterisk even when its param was genuinely optional. - A second pre-merge cleanup pass on #232 removing invented primitives and
duplication, and fixing bugs found alongside them:
- The curated Filter field (Dashboard filter bar and the bottom-drawer
Filter preview) is rewritten to reuse the shared
var-combocombobox primitive (combobox.js'screateCombobox/wireComboInput, the same.var-combo/.var-input/.var-combo-listclothes the enum/recent/ relative-time fields wear). It previously hand-rolled its own listbox with CSS classes that did not exist, so the dropdown rendered as an unstyled inline bulleted list that pushed the clear (×) button out of place. - The
{severity, code, message, …}diagnostic factory duplicated across three Filter modules is now one sharedcore/diagnostics.jshelper (#236). - Filter sources reuse the tile wave's generation/abort guard
(
supersedeSlot/slot.gen) instead of a parallel re-implementation (#237). - Curated Filter fields are seeded from a persisted last-known bundle
(
asb:filterCurated) so they paint as the searchable dropdown immediately instead of flashing a plain text input for one frame on each load (#234). - The result-presentation picker no longer breaks for a
table-typed panel: it mapped to apanel:tablevalue that matched no option, leaving the select blank with no way back to Table — a table panel now resolves to the(auto)entry (Table's surface remains the adjacent Table view). - Typing SQL now re-evaluates the whole Spec validator graph only for Filter-role tabs (whose diagnostics depend on the SQL), not on every keystroke of every tab.
- The result-presentation
<select>now switches the drawer to a preview consistently: it shows aPreview…placeholder while on Table/JSON, so picking any entry (a chart, Logs, KPI, Text, or the Filter role) — even the query's current one — is a realchangethat switches to that preview. Previously it reflected the current type/role, so re-picking it fired no event and the view never switched. - The Filter drawer preview is now a result-grid consistent with the
Table/JSON views — columns
name · options · type · example, with the interactive per-helper combobox in theexamplecell (no clear ×).
- The curated Filter field (Dashboard filter bar and the bottom-drawer
Filter preview) is rewritten to reuse the shared
examples/query-log-explorer.jsonreworked: thehourslookback is replaced by a real DateTime range —from(required) andto(optional) onevent_time— applied to everysystem.query_logpanel;namePatternis replaced by a universal optionalsearchover the query text (and the exception message in the log panel); the three per-shape Filter favorites are consolidated into a singleFiltersource returninguser+query_kind, and the exception-code Filter and "Errors over time" panel are removed.
- Explicit, favorited KPI queries now render as full-width Dashboard KPI
bands instead of nested inside a generic gray tile (#240). A KPI band
spans every Dashboard column regardless of the selected Full width/Report/
2-column/3-column layout; consecutive explicit
panel.cfg.type==='kpi'favorites merge into one flat, wrapping card stream (favorite order, then result-column order), with no per-favorite name, description, or rows/time/bytes footer. Loading, missing-parameter, and error states render as compact in-stream state cards (naming the source query); one source's failure never hides its band siblings; warnings render below the band, each naming its query. Cards use controlled, content-driven widths (160–320px desktop, full row under 520px). An auto-detected (unconfigured) one-row KPI result is unaffected — it remains an ordinary tile, following the selected layout, exactly as before.src/ui/kpi-panel.jsgained a lower-levelrenderKpiCards()primitive (the individual card nodes, decoupled from the workbench's.kpi-panel/.kpi-gridwrapper) that both the workbench preview and the newsrc/ui/dashboard-kpi-band.jsmodule share;core/dashboard.jsgained the purepartitionKpiBands()grouping. No schema change, no new runtime dependency. src/core/clickhouse-type.jsis now the sole ClickHouse type-expression parser (#238), replacingparam-type.js's independent regex parser; the latter is now a thin compatibility projection deriving everything from the shared AST. The parser gained numeric/string literal argument nodes (Decimal(P,S),FixedString(N),DateTime('tz'),DateTime64(P,'tz')), fullEnum8/Enum16member parsing (explicit/implicit codes, escaped and$tag$…$tag$heredoc names — the tokenizer itself is now heredoc-aware, so a heredoc body may contain(,),,, or a stray quote character without corrupting the surrounding parse), and distinctunwrapNullable/unwrapLowCardinality/unwrapValueTransparentWrappers/analyzeTypeModifiershelpers —Nullable(...)andLowCardinality(...)are no longer conflated.LowCardinality(T)is now transparent for declared-parameter value handling: validation, serialization, relative-time resolution, and Enum membership all use exactlyT's rules, recursively (including insideArray(LowCardinality(T))) — previouslyLowCardinality(...)-wrapped parameters fell through to permissive, unvalidated passthrough. Declaration- identity comparison (conflict detection) moved from a whitespace-deleting string compare to a canonical formatter that is whitespace-insensitive outside quoted/heredoc content but wrapper-sensitive —LowCardinality(String)is a different declaration fromString, never treated as the same conflict-free type.LowCardinality(Array(...))/Nullable(LowCardinality(...))are syntactically permissive but never treated as an ordinary supported scalar by the Dashboard Filter helper reader;LowCardinalitywrapping anEnum8/Enum16(any nesting order) is now rejected everywhere — no ClickHouse version accepts it (live-verified:ILLEGAL_TYPE_OF_ARGUMENT), so it degrades to opaque passthrough for parameters and is never a supported Filter/KPI scalar, instead of offering Enum dropdown/membership behavior for a declaration that can never bind on a real server. KPI and Filter helper behavior is otherwise unchanged, now backed by the shared parser. No new runtime dependency.- Quote-delimited token scanning is now one shared primitive (#241):
src/core/quoted-span.js'sscanDelimited()is the single authoritative backslash/doubled-delimiter rule for'…',`…`, and"…"spans, consumed by bothsql-spans.js(unchanged behavior) andclickhouse-type.js's tokenizer, which previously used a one-character backslash lookback that misclassified an EVEN-length backslash run before a closing quote as escaping it (Enum8('label\\' = 1)— a member name ending in one literal backslash — could drop the member, degrade the whole type to opaque/pass-through, or in one case throw). Also fixed: quoted Tuple member names (backtick/double-quote identifiers) now correctly decode a doubled delimiter (`ab`` /"a""b"→ `` ab ``/a"b), not just backslash escapes. - Saved-query Library JSON now uses the version 2 canonical model (#211):
every entry is
{id, sql, specVersion, spec}, with the complete Spec carried unchanged through local storage, tabs, panel edits, sharing, import/export, and merge. Version 1 files and share links are upgraded on read; exports, official examples, and generated Libraries now emit only version 2. Unknown Spec and panel fields are preserved for forward compatibility. - Builds now use a committed
package-lock.jsonandnpm ci(#157), making local, pull-request, and tagged-release artifacts resolve the same complete dependency graph instead of silently picking up newly published transitive versions. The lockfile includes esbuild's platform binaries as optional packages, so Linux CI and macOS development each install the correct binary without sacrificing reproducibility.
- Draggable pane splitters now use one quiet, consistent visual treatment (#227): sidebar, editor/results, schema/Library, schema-detail, and cell-detail handles keep a generous fixed hit area around a centered 1px theme-border line, then show the same 3px accent indicator on hover and throughout a drag without moving adjacent panes. The former permanently thick horizontal bars and horizontal-only center grip are removed.
- Schema search cascades through the database/table/column hierarchy instead
of flat-filtering table and column names independently (#208). A database
name match now shows that database and every one of its tables; a table
match shows its parent database, the matching table, and all its currently
loaded columns; a column match shows only the matching columns plus their
table/database context. Ancestors required by a match render as visually
open even when persistently collapsed, and a database with no matching
descendant is hidden entirely (previously every database always rendered,
even empty ones) — a non-empty filter with no matches at all now renders one
"No matching databases, tables, or columns." message instead of empty rows.
Search-driven reveals are a pure presentation-time projection: they never
write
state.expanded, so clearing the filter restores the exact prior expand/collapse state, and typing never triggers column-loading requests — a directly-matching table whose columns aren't cached yet shows none until expanded. Fixes a related cosmetic bug found in review: a click on a row shown open only via the search cascade (not persisted expansion) no longer flashes its chevron shut and back open (src/ui/schema.js).
- KPI panels now render one-row scalar and named-tuple results (#154) in
both the workbench and Dashboard through one shared reader and renderer.
The canonical Presentation Spec supports exact-name field metadata, nested
delta display semantics, units, rounding, colors, NULL text, and visibility;
explicit KPI queries own typed progress streaming and reject authored
trailing
FORMATclauses before sending a request. - The saved-query Spec editor now provides complete schema-driven native
CodeMirror autocomplete (#221). Root/nested properties, discriminated panel
branches, constants, enums, booleans, nullable values, defaults, examples,
object/array skeletons, and schema-owned snippets all come from the canonical
query.specschema rather than editor-owned lists. Annotated positions add cached last-successful result column names/indexes and dynamic field-configuration keys without executing SQL. Typing opens the same popup used by the SQL editor;Ctrl-Space, arrows, Enter, Tab, and Escape retain native CodeMirror behavior and the information pane shows schema documentation. Completion works against incomplete JSON through a non-persisted tolerant Lezer model, while the bottom Spec strip is now reserved for blocking errors only—warnings, informational diagnostics, valid-state messages, and missing dynamic data do not appear there or disable Save. - Complete canonical Library JSON contracts and a versioned codec (#224)
now cover the closed Library v2 and saved-query v2 envelopes as well as the
independently versioned, extensible query Spec. One pure parse/validate/
migrate/decode/encode boundary drives Open, Replace, Append, Save JSON,
examples, and historical local-storage ingress; unsupported future versions,
duplicate IDs, malformed timestamps, and corrupt storage fail atomically with
exact path diagnostics. New exports include a
$schemahint and validexportedAt; older v2 files without the timestamp remain readable under a documented compatibility policy. A manifest-driven strict Ajv build emits self-contained named validators, a schema catalog, and a single offline Library bundle while excluding/drafts/schemas and keeping Ajv plusajv-formatsout of the production runtime. - A canonical Draft 2020-12 schema and pure validation/introspection service
now define
query.spec(#220). All implemented panel branches carry schema-owned documentation, snippets, status, and result-column completion annotations; known fields are validated while unknown extensions and future panel types remain storable. Ajv compiles the schema at build time from a dev dependency into deterministic self-contained ESM, with stale generated files failing tests and builds. Stable exact-path diagnostics now drive Spec editor validation, synchronous atomic Save, import/Replace/Append, external Spec writers, and static runtime panel checks, while the existing app-owned feature-validator registry remains the layer for result/context rules. Checked-in examples, generator output, templates, and authoring-guide JSON are schema-validated in tests. - The workbench now has independent SQL and saved-query Spec JSON editor
modes (#212). A visible
SQL | Specswitch keeps separate per-tab drafts, undo/search state, dirty flags, and injected CodeMirror adapters. Spec mode adds local JSON formatting, folding, search, parse markers with line/column, and synchronously registered semantic validators keyed by exact path arrays; known static structure is now owned by the canonical schema added in #220, while unknown extension fields remain valid. Linked Save atomically commits SQL plus the current valid Spec in one Library write, with normalized Name/Description and all other fields/order retained; invalid Spec persists nothing. Spec is a lightweight editing mode: its toolbar contains only Format, Save, and the SQL | Spec switch, while Run, Explain, SQL Format, Export, Share, and Share’s global shortcut are SQL-only. Validation remains continuous through diagnostics and status. Library pencil, favorite, and Panel writers merge their changes into every valid open draft, preserving unrelated unsaved and extension fields; invalid JSON or a blocking schema/feature diagnostic stops the staged writer before mutation, and invalid JSON focuses the affected Spec tab. Reopening an already-open saved query activates its existing tab. No package or lockfile change was needed because JSON language support landed in #213. - A shared, injected read-only CodeMirror source viewer (#213) now provides
complete-text rendering, line numbers, local search, selection/copy, and
compartment-based wrapping for text, JSON, SQL, XML/HTML source, and plain
Markdown source. It mounts in either the app document or a detached document
realm and has explicit idempotent teardown. The editable SQL editor and the
viewer share only presentation/search extensions and the existing
.sql-*token classes; editor history, completion, hover, schema loading, drag/drop, tab parking, and state synchronization remain isolated behindEditorPort.@codemirror/lang-jsonand@codemirror/lang-xmlare the only added packages; the measured self-contained artifact grows by 18,063 bytes raw / 7,059 bytes gzip. - Iceberg Catalog Explorer example library
(docs/ICEBERG-CATALOG-EXPLORER-DEMO.md).
Content-only (no code changes):
examples/iceberg-install.jsoncarries generator entries that emit, from ordinary{param}filter inputs, the DDL for per-catalogice_meta_<catalog>Iceberg-metadata navigation views (DEFINER-locked, bucket-scoped S3 grant), a cross-catalogice_meta_allunion layer discovered live fromsystem.databases, a chmem memory-store seed, and a per-catalog drill-down mini-library — all emitted throughFORMAT TSVRawso the raw result cell copy-pastes byte-exact into a new tab.examples/iceberg-catalog-dashboard.json(BI) andexamples/iceberg-dba-dashboard.json(DBA, with snapshot-commit and metadata-version logs panels) dashboard the views with one shared optionalcatalogfilter on every tile. Built fromexamples/iceberg-templates/byexamples/mjs/build-iceberg-install.mjs/examples/mjs/build-iceberg-dashboards.mjs.
0.4.0 - 2026-07-13
- The detached Data view is now interactive: Panel switcher + query filters
(#185). The Data pane's Expand used to open a frozen snapshot; it now
opens a self-contained, re-runnable full-screen surface (real browser tab, or
the in-app overlay fallback) bound to the result's captured
source({sql, tabId, rowLimit, title, description}, attached byrun()on a normal row-returning result). The detached header shows that captured query title (a real heading; it also sets the browser-tab title) with the saved description clamped below it; a{name:Type}source gets a filter row built from the same shared control the SQL Browser and dashboards use (extracted tosrc/ui/filter-bar.js), and its Table | JSON | Panel switcher, sort, and column widths are local. Committing a filter or clicking Refresh re-runs only the detached query with full workbench parity — streaming, the server-side row cap, and real request abort — through one shared execution seam (app.runReadInto) that writes no tab/global state; a per-view generation guard + AbortController mean a stale or post-close response can never overwrite the current result. Filters read/write the same sharedvarValues/filterActive/varRecentstores (a value entered anywhere is offered everywhere; successful runs record recents via the shared recorder), the originating tab's ClickHouse session is reused when the source needed one, and the main workbench result/view/sort/panel/history and global running state are untouched. Copy always copies the current detached result. The Table/JSON/Panel dispatch is now one sharedrenderResultViewused by both the live pane and the detached view (no parallel copies). No new runtime dependency. (Dashboard tiles moved onto the same shared streaming seam in #193 — see Changed below.) - Schema column name and type are now independent drag targets (#186).
Dragging a column's name still inserts the SQL-safe quoted identifier;
dragging its type meta now inserts the complete schema-provided ClickHouse
type — including every Enum member — never the compacted display text
(e.g. dragging
Enum16(33 values)inserts the fullEnum16('Close' = -11, …)declaration). A newCOLUMN_TYPE_MIMEdrag payload (src/ui/dnd-mime.js) keeps this separate from the existing identifier drag; the CodeMirror drop handler consumes it with identifier precedence preserved. Icon/whitespace and type-less columns carry no type-drag payload; database/table row dragging is unchanged. - Dashboard: one four-way layout switcher with a new Full-width mode
(#184). The dashboard's two separate layout controls (Arrange|Report plus a
right-aligned Columns 2|3) collapse into a single segmented control —
Full width | Report | 2 columns | 3 columns— so every effective layout is one click away. Full width is a new experimental mode: one tile per row filling the entire available dashboard width (inside the existing page gutters) for horizontally expansive Grafana-style panels, keeping the normal Arrange tile height rather than Report's taller document look. State is unchanged —dashLayoutgains awidevalue alongsidearrange/report(no preference migration; existing selections stay valid) anddashColskeeps its 2/3 meaning. The control carries aDashboard layoutgroup label and per-button tooltips, exposes exactly onearia-pressedbutton, and layout changes stay presentation-only (no tile re-query). The saved-query visualization config is promoted from "a chart, plus special cases" to a first-class panel union —panel.cfg.type ∈ bar | hbar | line | area | pie | table | logs | text— designed in the results pane's new Panel tab (a Type picker + per-type config) and rendered identically as dashboard tiles by one registry (src/ui/panels.js), so drawer preview ≡ tile by construction. Previews never execute SQL: they render from the tab's last explicit Run (the text panel needs no result at all — its Markdown lives inpanel.cfg.content, rendered by an in-house safe subset parsed to an AST and built as DOM, raw HTML inert, http(s) links only, no new runtime dependency). Thelogspanel names its{time, msg, level}columns (with convention-based auto-detection coveringsystem.text_logand OTel tables);tableis the explicit plain-grid choice; text panels save with empty SQL, export their content to Markdown, and are skipped by the.sqlscript export. The dashboard now partitions favorites before execution — a text favorite renders immediately with zero queries — and nothing multi-row is skipped anymore: unconfigured results go log-shape → chartable → table (the log-shape signal outranks autoChart), explicit panels never vanish (zero-row explicit panels show an honest "0 rows" state), and grid tiles keep sort/column-width state across refreshes while the schema is unchanged. Tile fetches now carry best-effort server caps (max_result_rowssentinel +max_result_bytes, overflowbreak) with a guaranteed 5,000-row client trim and an honest "first 5,000 rows fetched" footer note (#164). - Library format: the
panelfield (#166).library.jsongains an optionalpanel: {cfg, key?}withversionstaying 1 (additive; older builds drop the unknown field). One pureupgradeSavedEntry()runs at every ingress — localStorage startup, JSON import, Replace/Append/merge, tab restore, share-link decode (including the OAuth round-trip stash) — so a user upgrading in place sees no visual change;view:'table'entries with a latent chart migrate losslessly (the chart roles ride in a nested stash and prefill a switch back to a chart type). Rollback safety: saves, exports and share links dual-write a legacychartmirror for chart-family panels for one release (derived via one seam, so mirror and panel cannot drift; removing it next minor requires the upgrader to stripchartwhenpanelexists). Unknown panel types and unknown cfg fields are preserved, never silently stripped.
- Data-skipping indexes moved off schema graph cards into the detail drawer
(#179). Expanded schema-graph cards no longer render the inline
idx: …line: because a card's width is the widest rendered text line, long index names/types used to inflate the card and distort graph spacing (a heavily-indexed table spread far wider than the same columns with no indexes). Card geometry (width/height/layout/spacing) is now completely independent of index count, name, and type. The full index metadata instead appears in a new Data-skipping indexes (N) section of the bottom detail drawer, after Columns: every index (never a capped subset), with Name, Expression, full Type (type_fullpreferred sobloom_filter(0.01)/tokenbf_v1(…)stay distinguishable, falling back totype), Granularity, and compressed size — long Name/Expression/Type cells ellipsize with the full value on hover, and the section is omitted when a table has no skipping indexes. The rows are fetched once per detail-open (one read added toloadTableDetail's existing parallel batch); the now-dead skip-index readloadSchemaCardsissued on every graph load is gone. No new runtime dependency. (Follow-up to the #177 compact-type work.) - Dashboard tiles now stream through the shared
app.runReadIntoseam (#193, follow-up to #185). Every query-backed tile runs on the same execution path as the workbenchrun()and the detached Data view instead of the bespokequeryDashboardTile(FORMAT JSON, whole-responseparseJsonResult) it used before — gaining streaming transport, bounded client memory, live progress, and real per-tile cancellation via anAbortController. The read-only guard is preserved (readonly:2+max_result_bytesride in the request; the row cap is thenewResult('Table', DASH_TILE_ROW_CAP)client trim against a servermax_result_rows = CAP + 1sentinel, so exactly-cap results are not flagged and>CAPare trimmed and flagged). Each wave reserves its slot generation at creation (aborting any in-flight request then) so a queued Refresh worker a newer filter wave has superseded discards itself without issuing — closing a stale-wave race; targeted filter re-runs now take one token preflight and the same 6-way concurrency pool as full Refresh. While a tile streams, only its loading placeholder's row count updates — panel classification and rendering happen once, on completion, so charts are never rebuilt mid-stream. Two small, deliberate behavior changes: a Dashboard panel query with an explicitFORMATclause is now rejected with a clear tile error (the streaming parser only understands the structured stream, so a strayFORMATwould silently corrupt the tile), and the tile footer always shows ms (wall-clock) and bytes (streamed progress). The now-unusedapp.runTile/queryDashboardTile/dashboardTileSql/parseJsonResultmachinery is deleted so future cap or settings fixes can't apply to only one path. No new runtime dependency; no change to the workbench or detached view. - One authoritative ClickHouse lexical scanner + structural lexer replaces the
legacy highlighter tokenizer (#182, supersedes #141). All string-based SQL
analysis — statement splitting, parameter detection, optional-block/format
handling, type display, completion, FROM/JOIN scope, and parameter-comparison
inference — now runs on the shared
core/sql-spans.jsscanner ({kind, start, end, closed}spans, authoritative) and the new offset-bearingcore/sql-lex.jsstructural lexer. The old[type, text]tokenizer incore/sql-highlight.js(and the private comment/quote skippers duplicated acrossformat.js,type-display.js, andoptional-blocks.js) are removed; its fallback keyword/ function sets move tocore/sql-reference.js. CodeMirror 6 keeps sole ownership of editor parsing/highlighting and gains thehashComments/slashComments/doubleDollarQuotedStringsdialect flags. Lexical fixes that follow from the unified scanner://comments, restricted#(comment only before space/!), nested block comments,$tag$heredoc opacity (incl. heredoc Enum members), quoted-identifier escapes, and correct trailing-FORMAT/ DDL schema-refresh classification after every supported comment form. No runtime dependency added. - A panel-config edit now dirties the tab like a SQL edit, and an untouched
auto-derived config is no longer frozen into the entry on Save (#166).
Previously the Chart tab silently persisted whatever autoChart last derived;
now
panelpersists only when restored or explicitly configured, and the preview renders a clone (render never mutates tab state). The detached Data Pane's third view is now a read-only render of the source tab's panel (previously an editable chart with its own config bar). - Grid renderer extracted into its own module with shared state wiring
(#167). The sortable/resizable data grid (
renderGrid), the column-resize primitives (colResizeWidth,resizeHandle,reapplyWidths), and a newrenderGridViewadapter now live insrc/ui/grid-render.js. The adapter centralizes the sort-update → repaint choreography that the main results table, the script-result rows viewer, and the detached Data pane each hand-rolled — the caller still owns where sort/width state lives and what a repaint means, so every surface keeps its exact state lifetime and repaint scope. Behavior-preserving; prepares the module boundary the Panels track (#166) builds on (atablepanel becomes the fourth consumer without another copy of the wiring). A grid consumer may now omitonCell(cell clicks are inert instead of a TypeError).
- Schema tree table actions open generated SQL in a new query tab instead of
overwriting the active editor (#180). Double-clicking a table used to
replaceEditor()the current document withSELECT * FROM … LIMIT 100, and Shift-click replaced it with the formattedSHOW CREATEDDL — both could destroy an unrelated query the user was mid-edit on. Both actions now go through the existingloadIntoNewTab()path instead, naming the new tab with the unquoteddb.tabledisplay name while the SQL itself still uses the SQL-safequalifyIdent()result. Fetching/formatting the DDL is now shared between the editor-replacinginsertCreate()(unchanged, still used by database rows) and the new tab-openingopenCreateInNewTab()via a commonfetchCreateSql()helper (src/ui/app.js,src/ui/schema.js). Database, column, drag, and expand/collapse behavior is unchanged. - The Logs rescue path deep-clones the saved panel configuration before
handing it to controls (#200). The rescue branch (#192/#195) fed Logs
controls
{ ...saved.cfg }, a shallow spread — any nested unknown field (the panel-config contract preserves fields it doesn't own, for forward compatibility) stayed aliased to the livetab.panelCfguntil the explicitonChangewrite-back.renderPanelViewnow uses the canonicalclonePanelCfghelper, matching the non-rescue path's already-deep-clonedresolvePaneloutput (src/ui/panels.js). No behavior change for the current string-valued Logs fields; this closes the gap for a future object-valued or extension field. - The detached Data view keeps its committed result visible during streaming
reruns (#198). Changing a filter or clicking Refresh used to repaint the
in-flight result on every network chunk, which (a) flashed
Query returned 0 rows.whenever a chunk carried column metadata before any data rows, and (b) destroyed and recreated the Panel's Chart.js instance on every chunk. The detached view now follows the same commit-on-success policy as dashboard tiles: streaming updates a lightweightRunning… <n> rows readstatus only, the previously committed Table/JSON/Panel (and its chart) stays on screen untouched, and the new result is committed and repainted exactly once after a successful current-generation completion. Failure, cancellation, supersession, and close all keep the previous committed result — and because the in-flight result is never painted, the old failure-time restore repaint is gone. Winning bound parameters are still recorded once; the committed toolbar row count no longer changes mid-stream (src/ui/results.js). Dashboard and workbench streaming behavior are unchanged. - The detached view's Logs (Panel) surface now scrolls (#185 follow-up).
A readonly panel renders straight into the block-level
.res-bodywith no.panel-bodyflex wrapper, so theflex:1; min-height:0that bounds.dash-logsin the docked pane never applied — the logs list grew to its full content height and.res-body'soverflow:hiddenclipped it with no way to scroll..res-body > .dash-logs(and the.panel-with-notewrapper) now takeheight:100%like the adjacent.chart-view, so the list is bounded to the pane and its ownoverflow:autoscrolls (src/styles.css). - The workbench Panel tab keeps Logs authoring controls available when a
saved Logs panel falls back (#192). A saved
{type:'logs'}panel whose Time/Message roles no longer resolve against the current result used to fall back to the ordinary fallback diagnostic and preview (usually Table) with no way to repair it — reselecting Logs from the picker is a same-type no-op, so the saved configuration was stuck.renderPanelViewnow derives controls from the saved Logs config whenever the fallback is a saved Logs panel (src/ui/panels.js), while the preview keeps rendering the existingresolvePanel()fallback + diagnostic unchanged; selecting explicit Time/Message/Level roles repairs the panel in place (marks the tab dirty, runs no SQL) and the next repaint renders Logs normally. Dashboard, detached, and other read-only surfaces are unaffected — the rescue is scoped to the editable workbench tab and strictly to a saved Logs type. - The Logs rescue's fallback preview is now read-only, so it can no longer
silently overwrite the saved config (#195). #192's rescue kept a saved
Logs panel's Time/Message/Level controls available while it fell back to a
Table or chart preview, but that fallback preview still rendered with its
own normal, writable workbench controls — a fallback chart's X/Y/Series bar
competed with the Logs controls above it, and editing the chart axes would
replace the saved
{type:'logs'}config with a derived chart config even though the chart was only ever a temporary stand-in. The rescue predicate (hasGrid && saved?.cfg?.type === 'logs' && resolved.fallback) is now computed once inpanelContext()and shared by both the toolbar Panel picker (which now shows Logs as the active type throughout rescue, never the resolved fallback type) andrenderPanelView, which renders the fallback preview withreadonly: true(suppressing the chart arm's config bar entirely) and noonCfgChangecallback. Repairing Time/Message/Level still writes the saved Logs config as before; choosing another type from the Panel picker remains the only way to explicitly convert away from Logs. - Logs role selectors now name a stale saved column instead of silently
showing
(auto)(#196). When a saved Logs role (time/msg/level) pointed at a column name no longer present in the current result, its<select>had no matching<option>, so the browser fell back to displaying(auto)— visually indistinguishable from a role that was never explicitly configured, so editing a different role could silently persist the stale name unnoticed.logsRoleSelect(src/ui/panels.js) now derives alogsRoleStateper role: a non-empty saved name that doesn't match any current column (case-insensitively, mirroringresolveLogsShape's matching policy) renders as a selected, disabled"<name> (missing)"option witharia-invalid/titleon the still-enabled<select>, so the user can pick a current column or(auto)to repair it, or leave it stored for later. Applies to all three roles (Level is optional, so a stale Level can be marked missing even while Logs renders normally). Rendering never mutates or canonicalizestab.panelCfg. (Also fixed in passing: setting a detached<option>'s.selectedbeforeappendChildis not reliably honored under happy-dom —panelSelectnow setssel.valueonce after every option is attached.) - Unbounded column types no longer crush width-constrained UI (#177). A
declared type with an arbitrarily long body — a giant
Enum16(…), a many-fieldTuple(…),Nested/Variant/AggregateFunction/JSON(…)— used to consume the schema-tree row (reducing the column name to zero width) or was blindly character-cut in graph cards (Enum16('Close' = -11, 'Err…). A shared pure formatter (core/type-display.jscompactType) now collapses unbounded declaration bodies to semantic summaries (Enum16(41 values),Tuple(12 fields),Array(Tuple(12 fields))— outer wrappers preserved; quote-aware, balanced-bracket, effectively linear scan; malformed input degrades to plain truncation) across the schema tree, the schema-detail table, column completion detail, and the schema graph cards. The full declared type stays reachable everywhere: row/cell hover titles, a per-column SVG<title>on graph cards, and the CM6 completion info pane (via a newfullTypeon column completion items). LongCODEC(…)chains in the detail table are capped the same way, and.tree-row .metagained a CSS max-width backstop so no future raw value can reproduce the layout failure.
- Optional SQL blocks
/*[ … ]*/with explicit filter activation (#165). A comment-wrapped predicate —WHERE 1 /*[ AND d = {d:String} ]*/— is included only while every parameter inside it is active; a blank filter now means "no filter" instead of blocking the run. The raw template is SQL-transparent (each block is a plain comment to any tool that doesn't know the convention, so it runs anywhere with all filters inactive), and values still bind only through native{name:Type}parameters — never interpolated. Wired through the #173 pipeline's two materializations: the all-active analysis view feeds the variables strip, the dashboard filter bar and affected-tile detection (block-only params get an "optional" affordance and never gate Run/tiles), and the execution view is what runs and exports — parameters of omitted blocks are never sent asparam_args. Activation is explicit state (state.filterActive, persisted alongsidevarValues, blank ⇒ inactive for text controls, not carried in share links). Non-row-returning statements are never materialized; nested, unbalanced, parameterless,;-containing, whole-statement, or*/-containing blocks produce clear errors; and the Format action skips a statement containing blocks (with a notice) rather than round-tripping a template through server-sideformatQuery(). - Typed client-side validation for
{name:Type}variable inputs (#170). A value is now checked against its declared type before the query is sent, for the numeric/scalar families that are cheap to validate purely:Int8…Int256/UInt8…UInt256(range-checked viaBigInt; wraps like256forUInt8are blocked client-side even though ClickHouse's param path would silently accept and wrap them),Float32/Float64(decimal / scientific notation,inf/nan),Bool(a narrow, never-invalidaccept-set — its live accept grammar isn't fully enumerable), andUUID(hyphenated or 32-hex compact).String/Array/Map/Decimal/Enum/Date*and any unrecognized type are always passed through unvalidated (Enummembership is #172;Date/DateTimerelative expressions are #169). New puresrc/core/param-validate.js(100% covered) plugs into #173's pipeline as its validation stage. A value isinvalidonly when ClickHouse's actual param-value grammar (verified live — not the SQL literal grammar) certainly rejects it; a plausible mid-typing prefix (-,1e, a half UUID) reads as neutralincompletewhile the field is focused and hardens to the inline error on blur/Enter/execute. The workbench var-strip and the Dashboard's global filter bar (#149 D3) share one small affordance (src/ui/var-field.js) for the invalid-field styling- reason tooltip; an invalid value gates exactly like an unfilled one (Run
disabled workbench-side, the tile placeholder dashboard-side). Also fixes
two dormant gaps the validation stage exposed: the Run button's disabled
state now reflects
invalid/source errors, not justmissing(previously only visually out of sync with the actual gate), and a field whose value fails serialization no longer rolls up asokinprepareParameterizedBatch's per-field state.
- reason tooltip; an invalid value gates exactly like an unfilled one (Run
disabled workbench-side, the tile placeholder dashboard-side). Also fixes
two dormant gaps the validation stage exposed: the Run button's disabled
state now reflects
- Relative time expressions for date/time variables (#169). A
Date/Date32/DateTime/DateTime64(N)(anyNullable(…)-wrapped) variable now accepts Grafana-grammar relative expressions —-1h,now-7d,now/d— alongside absolute values: the stored value is the expression, so it re-resolves against "now" on every workbench Run, Dashboard load/Refresh, or filter-change wave instead of freezing a timestamp the moment it's typed. New puresrc/core/relative-time.js(100% covered) —resolveRelativeValue/isDateLikeType/resolveVarValues— parses the grammar (case-sensitive units;s/m/hoffsets are fixed durations,d/w/M/yoffsets and all/urounding are local-timezone calendar arithmetic with DST-safe "same wall-clock time" semantics and month-end clamping) and formats per declared type (local calendar date / integer epoch seconds / epoch seconds with anN-digit fraction — live-verified against ClickHouse 26.3.13'sparam_*path). A near-miss expression (startsnow…or sign+digits but fails to parse) gates via #170's existing invalid-field machinery, following its exact incomplete→invalid timing: neutral and non-blocking while still being typed (now-,-1, ordinary keystrokes toward a valid expression), and hardened to the visible inline error only on blur/Enter/execute — no second affordance, no separate timing model (review fix, post-merge). Plugs into #173's pipeline as the realresolveRelativeValuestage (previously identity). The UI is the first consumer of a new accessible type-to-filter combobox primitive (src/ui/combobox.js, #174 §1 — full keyboard map, ARIAcombobox/listbox/optionroles, IME-composition safety, mousedown-before-blur commit,aria-describedbywired to the preview/error element) composed insrc/ui/relative-time-field.jswith a live preview of the resolved instant as a human-readable UTC ("server time") calendar string (2026-07-11 13:23:45) — never the wire value actually sent (which stays epoch seconds/date per the declared type), and never converted to the viewer's local zone, so the same instant reads identically for every viewer; both the workbench var-strip and the Dashboard's global filter bar (#149 D3) upgrade their date-like fields to it, unchanged for every other type. Resolved instants are FLOORED to the whole second for every date/time type (never rounded), soDateTimeandDateTime64(0)agree on the same instant and a resolvednownever lands a second in the future. - Shared parameter pipeline (Phase 7.0) (#173). A pure, two-phase,
multi-source parameter pipeline —
analyzeParameterizedSources(per-field declarations across all occurrences, per-source requiredness, cross-source type-conflict diagnostics) andprepareParameterizedBatch(per-source{statements, missing, invalid, errors, runnable}verdicts, immutableboundParamssnapshots, per-param field states) — that #165/#169/#170/#171/#172/#160/#175 plug into (#165/#169/#170 via their own stage seams, real from their entries above; #171 below reads theboundParamsoutput directly rather than overriding a stage; #172/#160/ #175 still identity/unknown until they land). Includes a typed serializer:Array(T)values bind as ClickHouse array literals with correct quote/backslash escaping, big integers (UInt64/UInt128/UInt256,Int128/Int256) stay strings end-to-end (never through a JSNumber), and scalar-string values remain byte-identical to before. Serialization is per-statement by the local declaration; a structurally incompatible stored value blocks only its own source — on the dashboard, one bad tile never blocks its siblings. Execution waves share one separately-injected wall clock (env.wallNow), distinct from theperformance.now-based duration clock. - Per-variable recent-value history with an MRU dropdown (#171). Every
{name:Type}field now remembers its 10 most recently used values, offered in a dropdown on focus (type-to-filter, click inserts, Esc/blur closes — the field stays free-text). A value is recorded only when a statement or dashboard tile completes successfully, read straight from that statement's #173boundParamssnapshot: a failed statement records nothing, statement 1 of a later-failing script still records, a param confined to an inactive #165 optional block is never recorded, and an empty string is never recorded even when actively bound. A relative-time value (#169) records the typed expression (-1h), never the resolved instant, so it keeps re-resolving on reuse. New puresrc/core/recent-values.js(100% covered):recordRecent/clearRecent/clearAllRecent(MRU insert, exact-string dedupe/move-to-front, a 10-per- name cap and a ~100-entry global-LRU total cap across all names), plusvisibleRecents/filterRecentValues/recentOptions— the render-time helper that hides (never deletes) a recent #170's validator marks invalid for the field's current declared type, so it reappears once viewed through a compatible declaration. Storage isasb:varRecent(versioned + sequence-stamped, name-keyed likevarValues, shared/ persisted the same way — plaintext, same exposure). Two new UI seams compose the existing accessible combobox primitive (src/ui/combobox.js, #174 §1) rather than building a second control:src/ui/recent-field.js(recents-only, for every non-date-like field) and an extension tosrc/ui/relative-time-field.js(adds an optionalgetRecentsthat upgrades its dropdown into ONE combined list — presets first, then a "Recent" group). A separatesrc/ui/combo-footer.jsrenders the per-field "Clear recent" affordance as its own smallposition:fixedbox anchored under the listbox (kept out ofcombobox.jsitself, whoselistElis fully owned by its own render pass, and out of the listbox's ownrole="option"items, where a destructive action would be an ARIA regression). The header File menu gains a "Variable history" section: a "Remember recent variable values" preference (recording off, existing history retained until cleared) and a "Clear all recent values" action — the closest thing the app has to a settings surface today. Both the workbench variables strip (app.js) and the Dashboard's global filter bar (#149 D3, dashboard.js) record on their respective success paths and share the same dropdown/footer wiring. - Enum variables render as a dropdown of their allowed values (#172), two
tiers, zero new network requests. v1 (declared type, both surfaces): a
variable declared
{name:Enum8(…)}/Enum16(…)(Nullable(…)unwrapped) gets a dropdown listing its member names, parsed straight out of the declaration by new pureenumMembers/enumValues(src/core/param-type.js, 100% covered — reuses the shared string-span scanner so escaped quotes ('a''b'), braces ('}'), spacing variants, negative codes, and unicode member names all parse exactly like ClickHouse's own literal grammar, and implicit auto-numbered members —Enum8('hello', 'world'),Enum8('One' = 1, 'Two', 'Three')— get their real codes with ClickHouse's previous-code+1 rule). Membership is enforced (#170's invalid affordance, blocking) via a newparam-validate.jsbranch: a LIVE-VERIFIED server fact (ClickHouse 26.3.13) is that a bare numeric code string (1) is ALSO accepted for a declared Enum param, binding as the member with that code — so validation accepts member names AND matching numeric codes (a strict digit prefix of a declared code, like1on the way to code12, stays neutral while typing, same as a member-name prefix), rejecting everything else with a reason that lists (and, past 8, samples + counts) the allowed values. Works in both the workbench variables strip and the Dashboard filter bar, since the declaration travels with the tile SQL. v2 (schema-cache inference, workbench only, suggestions — never blocking): a plain{s:String}compared directly to a column (col = {s}or{s} = col, qualified/aliased forms included — an expression,IN,BETWEEN, or the same param compared to two different columns all yield no match) whose cached type (#84's schema cache) is an Enum offers the identical dropdown, purely as suggestions: the declared type staysString, so a non-member still executes — the cache can lag the server. New puresrc/core/param-comparison.js(paramComparisonColumns, 100% covered) finds the syntactic column reference; a newresolveComparisonColumnTypeinsrc/core/from-scope.jsresolves it against the statement's FROM scope and the loaded schema (exactly one confident match required — ambiguous or not-yet-loaded degrades silently to a plain input, upgrading automatically once the column lands on the existing idle-tick loader; a background load that completes while the user is focused inside the variables strip DEFERS the strip rebuild until that field blurs, so it never steals focus, wipes in-progress text, or closes an open dropdown mid-typing). Third consumer of the shared combobox (src/ui/combobox.js, #174 §1) via newsrc/ui/enum-field.js: enum values render under a "Values" header once recents (#171) are also wired (paired labeling, exactly relative-time- field.js's own rule), a largeEnum16(thousands of members) type-to-filters the COMPLETE member list first and only then caps the rendered rows atENUM_DROPDOWN_CAP(≈200) with a "type to narrow" hint, so a member past the cap stays reachable by typing.param-scan.jsgained a position-carryingscanParamOccurrences(the existingscanParamDeclarationsis now a thin wrapper over it) so v2 can locate each param occurrence's FROM scope.
- Phase 7 whole-branch review fixes (#173/#165/#169/#170/#171/#172).
Type-conflicted
{name}declarations now surface: the field carriesconflictthroughfieldControls, degrades to a plain text input on both the workbench var-strip and the dashboard filter bar (never a one-sided enum or date control), and shows an amber.is-conflictwarning whose tooltip lists the disagreeing declarations. The #172 v2 schema-cache scan runs on the analysis materialization, so acol = {p}comparison inside a/*[ … ]*/optional block gets its dropdown too; and comparison-column conflicts are decided on resolved identity, not raw qualifier text (e.status+statusin a single-table query now match; JOIN sides still don't). "Clear recent" now also empties the open dropdown list; a recent that duplicates a rendered enum member/preset is no longer listed twice; every execution path (run / script / both exports) captures its prepared args at wave start, so a value edited during a token-refresh await can no longer desync from the gate; the array-element serializer now shares the validator's live-verified Int/Float token grammar (rejects007, acceptsinf/nan); and the var strip no longer analyzes the same SQL twice per editor keystroke. Manual-testing follow-ups (PR #176): a date-like field's combined dropdown now lists Recent first, then Presets (a recorded expression that duplicates a preset surfaces under Recent, not Presets — the enum/plain-recents fields are unchanged, still Values/plain-first); the "Clear recent" footer no longer lingers on screen after an option is picked via mousedown (combobox.jsgained a sharedonClosehook so every field module's footer hides on the same close path, not just focus/input/ keydown/blur); and the README's Enum section now spells out that a bare{o:Enum8}/{o:Enum16}is rejected by ClickHouse (Enum data type cannot be empty) rather than inferring the dropdown. - Multi-statement SQL now binds query parameters per statement everywhere
(#155, absorbed by #173).
paramArgsgated on the leading keyword of the whole text, so a favorite likeSET x = 1; SELECT {year:UInt16}never receivedparam_year; every gate/exec call site in the workbench and the dashboard now consumes the pipeline's per-statement batch instead. - Schema panel: a broken table in one data-lake-catalog database no longer
hides every catalog database's tables (#162).
loadSchemaqueriedsystem.tablesacross every database in one shot; once ClickHouse resolves per-table metadata for aDataLakeCatalog(Iceberg/Glue/…) database, a single unresolvable table there either aborts the whole query or (depending ondatabase_datalake_require_metadata_access) silently drops tables from other, healthy catalogs too — traced to a ClickHouse-side gap, reported upstream as ClickHouse/ClickHouse#110032. EachDataLakeCatalog-engine database is now queried separately, requesting onlydatabase, name— the one shape ClickHouse can resolve without opening each table's storage object, so one broken table can't take down anything else. Trade-off:total_rows/total_bytes/commentfor catalog tables show as zero/empty rather than being fetched.
0.3.0 - 2026-07-04
- Dashboard (phase 1): open your favorited Library queries as a read-only
dashboard in a new tab (#149). A new File ▾ → "Open as dashboard" item
(enabled once at least one query is starred) opens
/sql/dashboard— the same single served artifact, reached by a client-side route — and renders each favorited, chartable query as a live chart tile, reusing the existing Chart.js result view. The new tab is authenticated by a one-time, same-originpostMessagecredential handoff from the opener (both the target origin and the peer window are verified); a cold/bookmarked visit falls back to the normal login flow, which returns to the dashboard after sign-in. Tile queries run read-only (readonly=2), so a favorite that happens to contain a write is rejected server-side rather than executed on open/refresh. Tiles fetch with a bounded concurrency (so a large favorites list doesn't stampede the cluster), the auth token is resolved once before they fan out (no intra-tab refresh race), and a handed-off-but-expired token is refreshed rather than forcing a re-login. Single-row (KPI) and non-chartable favorites are skipped for now with an "N not shown" note. KPI tiles, global filters, drag-to-arrange layout, per-tile controls, and export arrive in later phases (#149 D2–D7). Known limitation: two tabs independently refreshing a rotating OAuth refresh token can race (BroadcastChannel sync deferred). - Dashboard (phase 2): Arrange / Report layout switcher (#149). A toolbar
below the dashboard header (the future filter bar) adds a primary Arrange |
Report segmented control: Arrange is the uniform multi-column grid, and
Report lays the tiles out as a single full-width scrolling column with
taller charts. A secondary Columns 2 | 3 control tunes the Arrange grid
(hidden in Report's single column). Both are presentation-only — switching
reshapes the grid and the chart tiles resize themselves, with no tile re-query
— and the choice is persisted per browser (
asb:dashLayout/asb:dashCols), surviving reloads and Refresh. Chart tiles were also brought closer to the design: the saved query description shows as a subtitle under the tile name, the chart now draws on the tile's own background (instead of the darker results-table background), and the value-axis gridlines are hidden on tiles (they read as noisy light lines on a dark panel). Drag-to-reorder and 1/2-column tile spans arrive in a later phase (#149 D4). - Dashboard (phase 3): global filter bar (#149, #152). A filter bar in
the dashboard toolbar renders one text field per
{name:Type}parameter detected across every favorited tile's SQL (dashboardParams, unique by name, first-appearance order) — absent entirely when no favorite has one. Fields share the same persistedstate.varValuesthe SQL Browser workbench already uses (#134): a value typed on the dashboard shows up in the workbench's variable strip for the same name, and vice versa. Typing debounces (~500 ms idle) before re-running only the tiles that reference the changed name — not the whole grid; Enter or blur commits immediately, bypassing the debounce. A tile whose SQL still has an empty/absent parameter never runs its query — it shows a distinct "Enter a value for: …" placeholder (excluded from the "N not shown" count, since one filter value away it becomes chartable). Tiles now live in stable per-favorite slots built up front and updated in place (loading/unfilled/error/chart) rather than inserted/removed, so a filter-driven tile flipping states repeatedly never reorders the grid or orphans its identity; each slot's fetch carries a monotonically increasing generation counter so a superseded in-flight response can never overwrite a newer edit's result.ch-client.js'squeryJson/queryDashboardTilegained an optionalparamsargument (backward compatible) to forwardparam_<name>args to ClickHouse®. Per-tile Type/X/Y overrides, KPI tiles, and dropdown/cascading filters arrive in later phases (#149 D5–D7). - Schema-aware, FROM-driven autocompletion (#84) — column completion now
fires while you type, driven by the statement's
FROM/JOINclause, so you no longer have to expand a table in the sidebar first. A new pure modulesrc/core/from-scope.jsresolves the caret's statement into its base tables ({db, table, alias}[], reusing the SQL tokenizer so strings/comments/;never fool it), and completion uses it three ways: aliases resolve (e.afterFROM events eoffersevents' columns), unqualified columns are scoped to the statement's tables (an unrelated loaded table's columns are no longer suggested), and columns load lazily on a debounced idle tick (300 ms, never on the keystroke path) — deduped via the existing'loading'sentinel, cached per connection, and the open dropdown refreshes when they arrive.db.table./table.qualification still works; with no FROM in view completion degrades gracefully to the global pool. Non-goals (v1): CTE/subquery-derived scopes,USING,SELECT *expansion, table functions. Builds directly on the CM6 editor (#21).
- Editor scrollbars are back, and the whole UI's scrollbars behave
consistently again. The console no longer renders at 1.2× via
html{zoom}(--zoomis now1— native size).zoom:1.2(= 6/5) landed element box sizes on fractional device pixels, and the leftover sub-pixel made scroll containers — the CodeMirror editor most visibly — read as "scrollable by ~1px" over content that visibly fit, painting a phantom scrollbar (the same rounding also drove the Safari viewport-unit divergence, #70). #145 had hidden the editor's bars outright to dodge it; with zoom removed the editor now uses the app's standard themed scrollbars like every other pane — a vertical bar for a long query, a horizontal bar for a long line, and nothing when the content fits. The UI is ~20% smaller than before; use browser zoom (⌘+) to enlarge. The now-dormant zoom-bridging machinery (--vp-zoommeasurement, Chart/menu-anchor/splitter zoom correction) is left in place for a separate teardown (roadmap #68).
- The SQL editor is now CodeMirror 6 (#21) — the deliberate 4th bundled
runtime dependency, replacing the hand-rolled textarea editor wholesale
behind the #143
EditorPortseam (src/editor/codemirror-adapter.js;main.jsswaps one injected factory). What changes for users: per-tab undo history (the shared textarea undo stack couldn't do this), real IME/touch editing, CM6's find/replace panel (⌘F), and measured-text rendering (no more fixed-glyph-width geometry). Highlighting still tracks the connected server'ssystem.keywords/functions— the sets now feed a ClickHouseSQLDialectswapped via a Compartment on connect — and completion keeps the purecore/completions.jscandidate set + ranking (CM6 renders the UI;filter: falsepreserves our order). Global shortcuts (⌘↵run,⌘⇧↵format,⌘S/⌘⇧S) stay on the document handler — CM6's conflictingMod-Enterbinding is stripped so an open completion can never swallow the run chord. Deleted with the cutover: the textarea adapter,editor-complete/intel/search,core/editor-{marks,geometry,brackets,search}and themaskLiterals-based literal masking (~2,600 LOC incl. tests) — execCommand undo, four-way scroll sync, and the editor'shtml{zoom}popover bridging all go with them. Signature help is dropped in this parity v0 (#60 rebuilds docs properly); function docs show as the completion info tooltip and on hover. Bundle: +402,911 B raw (+83%) / +132,903 B gzip (+85%) (484,674 → 887,585 raw; 155,810 → 288,713 gzip) — over the issue's raw estimate, accepted at the plan gate as the price of the Phase-4 editor foundation (#84 schema-aware autocomplete builds directly on this). - The SQL editor now sits behind an injected
EditorPortseam (#143): the hand-rolled textarea editor moved fromsrc/ui/tosrc/editor/and is the first adapter (createTextareaEditor) of a small port interface (src/editor/editor-port.js) injected throughcreateApp(env)like Chart/Dagre. The editor's state writes on typing (tab.sql/dirty, tab strip, Save button, #134 var strip) moved out of the adapter into an app-levelonDocChangesubscriber, and drag-and-drop MIME constants live in a neutralsrc/ui/dnd-mime.js. No user-visible change; this is the prep step that makes the CodeMirror 6 swap (#21) a reversible one-line adapter change. Bundle: +966 B raw / +428 B gzip (the port module — no new dependency).
- Query variables (#134): typed ClickHouse placeholders —
{name:Type}— are detected while you edit, and a single-line strip below the editor toolbar shows one input per variable (it scrolls horizontally when there are many, and is hidden when there are none). Run is disabled until every variable has a value. On execution the values ride along as ClickHouse's nativeparam_<name>query-string arguments, so the server substitutes them per the declared type (injection-safe;String/Identifier/DateTime/Array/Mapall work) — the SQL text is sent unchanged. Substitution applies to row-returning statements only, so aCREATE VIEW … {x:String} …definition is stored verbatim (matching ClickHouse parameterized views). Run, ⌘↵, Explain, and Export all honor the same gate and pass the same params. Entered values are shared by variable name across every query and persisted (asb:varValues), so a value typed once is reused — prefilled automatically — wherever the same variable appears, and survives reloads. (Distinct from #39's{{name}}composable-query CTE-merge — different syntax and purpose.) The literal/comment lexing shared with the script splitter now lives in one scanner (src/core/sql-spans.js, #139), which also makes detection quote-aware inside a type — a}in{e:Enum8('}' = 1)}no longer truncates the placeholder. - Best-effort mobile mode (#126): below a 768px viewport the shell becomes a
bottom-tab-nav workbench — a bottom bar switches between three full-screen
panels, Tables / Editor / Results, instead of squeezing the desktop
sidebar + split panes onto a phone. Tables has a Schema | Library segmented
toggle; Results carries a live badge (row count, or ● while a query streams).
The nav follows the natural flow: tapping a schema column jumps to the Editor,
loading a saved query opens it in the Editor, and running a query jumps to
Results. Every pointer-only affordance is removed rather than left
half-working on touch — all resize handles, the schema tree's native drag
sources and hover tooltips, the drag-to-drawer schema-graph drop target, the
graph-based
PipelineEXPLAIN view, and both graph fullscreenExpandbuttons; button-anchored popovers (Save, user menu) center on-screen; the editor/results toolbars swipe-scroll; and the header declutters so the File / theme / user-menu controls fit. The core SQL loop stays fully usable: tap to browse the schema (a db-row tap still draws its lineage graph, via #124), write, run, read results, chart, and the four text/table EXPLAIN views. A single breakpoint (MOBILE_BREAKPOINT_PX, mirrored by the CSS@media) drives an injectedmatchMediaisMobilesignal plusmobileView/mobileTab. - Click a closed database row to draw its schema graph (#124): expanding a
collapsed db in the tree now also draws its lineage in the bottom drawer, the
same as dragging it — collapsing again doesn't re-fetch or re-draw.
Drag-to-drawer is unchanged. On a schema with 50+ view/MV objects needing
EXPLAIN AST, the inline graph now draws progressively: the free edges (dependencies/target/engine-arg/dictionary — no extra round trip) paint immediately, then a single second layout merges in the view/MV source edges onceEXPLAIN ASTsettles, with a "resolving N/M…" toolbar readout. Below that threshold the fetch is fast enough that a visible first paint would just be flicker, so it still draws in one step. The loading placeholder / toolbar now has a working Cancel: it aborts the in-flight fetch and either keeps the already-drawn free-edges graph (marked partial) or falls back to the empty-results placeholder, whichever has something to show.
- The build now minifies
src/styles.csswith esbuild's CSS transform (same minifier already used for the JS bundle) instead of inlining it raw — the stylesheet was shipping every source comment and all its indentation verbatim. Cuts the served artifact by ~23 KB (~4.7%), no new dependency (esbuild already provides the CSS minifier). Investigated gzip too: every demo cluster already serves the SPA gzip-compressed (ClickHouse's HTTP server compresses any static-handler response when the client sendsAccept-Encoding: gzip, independent of any config in this repo) — verified ~54% smaller on the wire already, nothing to change there.
npm testflaked on Node 25 (#130): oneapp.test.jscase asserted a persisted preference by reading the ambientglobalThis.localStoragedirectly, which Node 25's native Web Storage (broken without--localstorage-file) leaves without agetItemmethod — aTypeErroron a clean local run, though CI (Node 22) was unaffected. The test now stubs an in-memory store the way thestorage/statespecs already do, insulating the assertion from the host runtime; an.nvmrcpins local dev to Node 22 to match CI.- The inline schema-lineage graph had a stale-write race (same class as #97):
running or Explaining a query — or dragging/clicking a second db/table —
while a lineage fetch was still in flight could let the stale fetch's
resolution land on the tab's new result once it finally settled, silently
showing an old graph instead of the actual query output. A request-identity
guard now drops any write from a superseded fetch. Separately, an abort
during the best-effort
system.dictionariesread inside the lineage fetch is now correctly propagated as a cancellation instead of silently degrading to "no dictionaries, continue". - Login screen: removed the footer's GitHub source link and "OAuth · credentials" method tag — noise a first-time visitor had to parse before signing in (#123). The screen's other reported complexity (multiple visible panels, the server picker) is a deployment config choice, not a code issue. Consolidated all login-screen config docs (OAuth setup, multiple IdPs, credentials login, the host/Advanced picker, the local-dev saved-connection picker) out of the README into a single new docs/LOGIN-SCREEN.md, linked from the README.
- The sidebar's schema/library splitter (drag to resize) stopped working
after #126: it resized
sidebar.firstElementChild, which #126's new mobile segmented control (hidden on desktop) had silently become instead of the schema pane — so dragging visibly did nothing. Now targets the schema pane directly. - Iceberg/Glue/Unity/HMS/REST-catalog databases showed zero tables (#122):
ClickHouse >=25.8 hides
DataLakeCatalog-backed databases fromsystem.tables/system.columnsunlessshow_data_lake_catalogs_in_system_tables = 1is set. The schema panel's table list, column expansion, table-detail pane, and schema-lineage graph now request that setting, falling back to the plain query (and remembering the fallback for the rest of the session, mirroringctx.authConfirmed) on servers older than 25.8 that don't have it.
0.2.0 - 2026-07-01
- Table/column COMMENT display: a table's
COMMENTnow shows as a native hover tooltip on its node — in both the compact inline schema-lineage graph and the fullscreen rich-card graph, never a drawn line, so it can't affect either graph's layout — and in the table-info panel's header next to the kind badge. Column comments show as a new (wide) column in that panel's columns table. The panel's "uncompressed" byte column is replaced by "size %" — the percentage of the original (uncompressed) size still on disk after compression. - Multiquery + run-selection (#83): run a
;-separated script (DDL / INSERT / SELECT) in one shot, or run just the highlighted text. ⌘+Enter auto-detects — a single statement behaves exactly as before; more than one runs sequentially (one ClickHouse request per statement, stopping on the first failure) into a compact per-statement summary grid. A non-empty editor selection runs only that text (the Run button flips to Run selection); a single selected statement still gets the full Table/Chart/EXPLAIN view. Row-returning statements show the first row inline (comma-separated) — click to open all rows (capped at 100) in a side pane; effectful statements show OK. Each grid row also shows that statement's own execution time (the toolbar still shows the script total). The click-to-open row pane is the same sortable + resizable grid as the main results table (one shared component). A script that needs cross-statement state (aCREATE TEMPORARYtable or a sessionSET) runs inside a per-tab ClickHouse HTTP session so that state persists across its separate per-statement requests; ordinary scripts run session-less. Cancel aborts mid-script. Splitting is purely lexical (src/core/sql-split.js), skipping;inside string/identifier literals and--/#//* */comments. Known limitation: anINSERT … FORMAT …with inline data containing;mis-splits — run those on their own. Format pretty-prints each statement of a script and rejoins them (;+ blank line; best-effort — an unformattable statement keeps its text), with a busy spinner on the button. Explain shows a clear message instead of a generic ClickHouse error when the editor holds more than one statement. Opening a saved query / history entry auto-runs only read-only queries — an effectful one (CREATE/ALTER/DROP/INSERT/…) loads into the editor without executing. - Result-row cap with a 100 / 500 / 1000 / 5000 / 10000 selector in the result
toolbar (default 500, a global preference persisted across tabs and reloads).
A normal
SELECTnow fetches at most the selected cap rather than pulling every row over the wire: ClickHouse stops cleanly at the cap server-side (max_result_rows+result_overflow_mode = 'break'), a small client-side guard trims the block-boundary overagebreakcan leave, and a "first N (capped)" badge appears in the stats row when the limit is hit. Changing the selector re-runs the current query, so raising the cap genuinely fetches more. The display grid now renders up to the selected cap (10000 actually shows 10000). EXPLAIN / PIPELINE / ESTIMATE runs are exempt. (#86) - Playwright e2e now runs on WebKit in addition to Chromium and Firefox, so
many Safari regressions on the
html{zoom}-based layout fail CI instead of shipping silently. README gained a Supported browsers stance: desktop Chromium/Firefox/Safari are supported; the full browser/ClickHouse/IdP matrix is tracked in #71. (#69) tests/e2e/zoom-support.spec.jsregression-guards the fullscreen-panel sizing mechanism (#70) on all three engines. Caveat now documented: Playwright's WebKit is not a faithful Safari proxy forzoom×getBoundingClientRect/viewport units — it behaves like Chromium there — so that path is verified manually (#71).- Small schema/EXPLAIN polish (#85): on ClickHouse ≥ 26.3, the EXPLAIN
plain/Indexes/Projections views render with
pretty = 1, compact = 1(older servers are unaffected — gated on the connected server's version); underscore- prefixed tables (_…) now sort to the end of each database in the schema sidebar and the lineage graph; opening a table's detail pane (fullscreen schema graph) shows a loading spinner immediately instead of a blank pane while its columns/partitions/DDL fetch; database rows show theircommentas hover text when set (else the existing shortcut hints, now also noting drag-to-graph); and the no-comment table hover text now also notes drag-to-insert. - Streaming Export (#87): a new Export button in the editor toolbar (next
to Share) runs the current editor query uncapped and streams the result
straight to a user-chosen file via the File System Access API
(
showSaveFilePicker→resp.body→ disk), bypassing the result grid entirely — memory stays flat regardless of result size. Format follows the query: an explicit trailingFORMAT <name>(in either order relative to aSETTINGSclause) streams verbatim with a matching file extension; otherwise it defaults toTabSeparatedWithNames. An inline progress banner (bytes written · elapsed · Cancel) tracks the export; Cancel aborts the stream and issues its ownKILL QUERY, entirely separate from the grid run's cancel state. A mid-stream ClickHouse error (after the response has already started, so the HTTP status can't change) is detected via theX-ClickHouse-Exception-Tagheader + the trailing__exception__frame and excised with a hold-back write buffer, so the error text is never written into the file — reported as "Export incomplete" instead. A session-less export of session-scoped SQL (aCREATE TEMPORARY TABLE/SETfrom earlier in the same tab) is guarded the same way the rest of the app handles those cases. Chromium + a secure context only (no File System Access API elsewhere) — the button stays visible butaria-disabledwith an explanatory tooltip. Replaces the old result-panel Export (buffered CSV/TSV download of the already-loaded grid); Copy is unaffected. - Script export (#99, a follow-up to #87): pressing Export on a
multi-statement script (instead of a single query) opens a directory
picker and runs the statements sequentially in one shared ClickHouse HTTP
session —
SET/CREATE TEMPORARY TABLEstate carries across statements the same way a run does. Each row-returning statement streams uncapped to its own file (NNN-slug.ext, matching the log's#column); non-row statements run for effect and log OK/error with no file. A live log pane (metadata only — never the exported rows, so a multi-million-row script export stays flat) shows status/file/bytes/elapsed per statement; Cancel aborts the active statement, issues its ownKILL QUERY, marks the rest Skipped, and keeps already-completed files. Stop-on-first-failure, no retry (unlike a normal script run, which retries a read-only statement once on a transientSESSION_IS_LOCKED) — a partially-written file shouldn't be silently re-attempted. A script with no result-producing statements shows a toast instead of prompting for a folder. - Detached-tab primitive + Data Pane Expand (#100): extracted the schema
graph's real-tab/overlay-fallback logic into a shared
openInDetachedTabhelper (src/ui/detached-view.js), now used by the schema graph, the EXPLAIN pipeline graph, and a new Expand button next to Copy in the results toolbar. Expand opens a snapshot of the current grid — sortable, resizable, with its own Copy, and the full Table/JSON/Chart switcher (same as the inline pane, but scoped locally: switching view/chart config there never touches the live tab's own state) — in a real browser tab, falling back to the in-app overlay when a pop-up can't be opened. It doesn't live-update if the query is re-run afterward. Pipeline's Expand now also opens in a real tab (previously overlay-only); the schema graph's existing tab/overlay behavior is unchanged.app.state.detachedView(a count) tracks how many detached views are open at once. Along the way, fixed Chart.js rendering nothing (a 0×0 canvas, then laid-out axes with no visible bars/points) when its canvas lives in a detached tab's own document — Chart.js's responsive-sizing and resize-triggered relayout read through APIs bound to the window its own module runs in, always the main window;renderChartnow forces an explicit resize +'resize'-mode update off the canvas's own (realm-agnostic) geometry once it's attached. Also mirrors the app's favicon into every detached tab (afaviconHrefseam, same pattern as the existingstylesTextone) —about:blankships neither, so a real tab previously showed the browser's generic icon. - Cell-detail drawer resize (#101): the right-hand drawer used by both the
cell-detail view and the rows viewer now has a drag handle on its left edge
(
splitters.jsgains a fourth'drawer'axis alongsidecol/sideRow/row), clamped to320px..92vwand persisted ascellDrawerPx— one shared width for both. Fixed a click-through: finishing a resize drag with the mouse released over the backdrop (instead of the panel) previously closed the drawer, since the browser's post-mouseupclicktargets the nearest common ancestor of the mousedown/mouseup targets, bypassing the panel's ownstopPropagation. Closing the drawer mid-drag (e.g. Escape while the mouse button is still down) now also cancels the in-progress drag and reverts the width, rather than leaving stray listeners that would persist a stale width or swallow a later, unrelated click. - UI consistency polish (#102): the schema tree's expand/collapse chevron
now rotates a single icon instead of swapping between two glyphs, matching
the login screen's Advanced disclosure — the rotation actually animates
(
flipChevronrestores the pre-toggle angle and forces a layout read before the target, sincerenderSchemarebuilds the row's DOM on every toggle and a freshly-created node has no "from" state for its CSS transition to interpolate from). The share toast can be dismissed early by clicking it (it no longer blocks clicks while visible); its auto-hide timer now lives on the toast element itself rather than a module-level field, so a toast in a detached tab's document can't clobber one in the main document's. Opening the user menu or the File menu now autofocuses a sensible first item (Log out / New Library).shortcutsOpen,editingSavedId, andbannerDismissedFormoved intostate.jsas signals, consistent with the rest of the ADR-0001 migration — no behavior change.
- State reactivity now uses
@preact/signals-core(the third bundled runtime dependency), adopted incrementally per ADR-0001: the tab list, side panel, run state (running/resultView), the library title, and now the schema panel (schema/schemaError/schemaFilter) repaint via signaleffects instead of manual render calls. No user-facing behavior change. A Preact schema-panel spike was evaluated and rejected — the app stays framework-free (ADR-0001 addendum); the schema slice is the documented imperative exception, converted with a replaced Set-valuedexpandedsignal and reference-replaced column loads rather than in-place mutation. This completes the migration. (#88, #91) - Chart-type-aware row cap (#109): the flat 500-row chart cap is now a
per-type lookup (
chartRowCap(type)/CHART_ROW_CAPSinsrc/core/chart-data.js) — Pie 30, Bar (horizontal) 500, Column 1000, Line/ Area 5000 — matching each chart shape's actual readability ceiling instead of one eyeballed number. Switching chart type re-slices to the new cap and updates the truncation note in lockstep.
- A newly created, still-empty database (e.g.
CREATE DATABASE) never appeared in the schema tree, even after a reload/relogin:loadSchema()only listed databases that had at least one row insystem.tables. It now enumerates databases fromsystem.databasesand attaches tables where they exist, so an empty database shows up immediately. Separately, the schema tree didn't refresh after running DDL at all —CREATE/DROP/ALTER/RENAME/TRUNCATE/ATTACH/DETACH/EXCHANGEnow auto-reload the schema on a successful run, so the tree stays in sync without a manual page reload. - Multiquery scripts no longer fail intermittently with "Network error". A
ClickHouse HTTP session is now attached only when the SQL actually needs one
(a
CREATE TEMPORARYtable or a sessionSET), or when the tab already opened one (sticky, so that state persists across runs in the tab) — ordinary scripts run session-less, removing the session-lock / replica-affinity reset that surfaced (behind a proxy/LB) as a reset connection. When a session is in use, a transient failure is retried only when safe: aSESSION_IS_LOCKED(rejected before execution) or a connection reset on a read-only statement. A connection reset on anINSERT/DDL is not retried — it may have executed server-side, so it's surfaced as "the statement may have executed; re-run manually" rather than silently double-applied. - The
session_id/query_idfallback used whencrypto.randomUUIDis unavailable (non-securehttp://contexts) now mixes inMath.randominstead of only a coarseperformance.now(), so two tabs can't mint the same id and collide on the session lock. - Result-table column resize now uses a splitter model: dragging a column's right edge trades width with its right neighbor (the table's total width and the other columns stay put), instead of growing the whole table and shifting later columns sideways. Dragging the last column still widens the table. Applies to the data grid, the multiquery script grid, and the script-row pane (one shared grid).
- The fullscreen schema / EXPLAIN graph panels were mis-sized on Safari (#70).
They size off viewport units, and engines disagree on how
vw/vhinteract withhtml{zoom}: Chromium's ignorezoom(so100vhovershoots one screen by the zoom factor and must be divided back), but WebKit/Safari's trackzoom, so the existingcalc(.../var(--zoom))correction shrank those panels to ~83%. The divisor is now measured at runtime (a100vhprobe vs the one-screen#root) and published as--vp-zoom— ~--zoomon Chromium, ~1 on Safari — so the panels fit exactly one screen on both. The rest of the UI was already correct on Safari (its pointer/caret/drag corrections self-calibrate to the live rect ratio). A@supports not (zoom: 1)rule still neutralizes the factor to 1 on engines that can't parsezoomat all. - The fullscreen schema graph's node detail pane could show stale data:
clicking table A then quickly clicking table B before A's fetch resolved let
A's slower response land last and silently replace B's already-mounted pane
and selection ring — last-resolved wins instead of last-clicked.
openNodeDetailnow tracks the most recently requested node per overlay document and drops a fetch whose click has since been superseded. (#97) - Cancelling or hitting a mid-stream error during a streaming Export (#87) left
no recoverable file at all: on Chrome's File System Access API,
writable.abort()leaves a hidden, 0-byte.crswapswap file behind and never materializes the visible target.streamToFilenowclose()s the writable instead, finalizing whatever bytes were already committed under the target handle, then renames it in place to<name>.partialviaFileSystemFileHandle .move()(Chrome 110+) so a cancelled/failed export leaves a clearly-labeled, inspectable partial artifact. Falls back to leaving the plain (non-renamed) file on browsers without.move()support, or if the rename itself fails (#105). createAppbuilt theappobject with adocfield, but every other module (explain-graph.js,results.js,schema-detail.js,file-menu.js,shortcuts.js,app.jsitself) readapp.documentinstead — never assigned, soapp.document || documentsilently always fell back to the globaldocument, harmless today only because the two happened to coincide in both production and tests.appnow exposesdocument(notdoc), and the fallbacks that were provably unreachable (verified per call site againstmakeApp()/ real callers) were dropped; the fallbacks that are deliberately null/minimal-app-tolerant (detached-view.js,explain-graph.js,schema-detail.js, andshortcuts.js— which has a dedicateddelete app.documenttest) were left untouched. (#106)- Every backdrop/panel modal (the cell-detail drawer, the rows-viewer pane, the
graph overlay, the file-menu confirm dialog, the keyboard-shortcuts modal)
closed on any
clickreaching its backdrop, without checking where the gesture'smousedownactually landed. A browser'sclickfires on the nearest common ancestor ofmousedown/mouseup, not themousedowntarget, so dragging a text selection from inside the panel past its edge before releasing produced aclicktargeting the backdrop directly — the panel's ownstopPropagation()never ran (the panel wasn't in that click's propagation path at all) and the modal closed, discarding the in-progress selection. A new sharedattachBackdropClose(src/ui/dom.js) tracks wheremousedownlanded and only closes on aclickwhosemousedownalso landed on the backdrop itself; all five call sites now share it instead of each pairing anonclick: closebackdrop with anonclick: stopPropagationpanel. The cell-detail drawer's resize-drag one-shot click-swallow listener (#101) is superseded by the same general fix. (#110) - The fullscreen schema graph's rich node card had no overflow cap on its
idx:skip-index line — unlike columns (capped atMAX_COLSwith a "+N more" row), every skip-index was joined onto one unbounded line. A heavily-indexed table (e.g. an OTel-style log table with a bloom filter per Map key/value plus a tokenbf on the body) produced a single line 1700px+ wide, blowing the card — and the whole graph layout — out of proportion.buildCardModelnow caps the line atCARD.MAX_IDX(6) with a "+N more" suffix, mirroring the columns' overflow pattern.
0.1.5 - 2026-06-29
SECURITY.md: private vulnerability-disclosure policy + theconfig.jsonthreat model (it's served to browsers — prefer a PKCE public client; lock the redirect URI if aclient_secretis unavoidable) and the CSP/token baseline (#72).- In-app build stamp: the build bakes
v<version> (<short-commit>)intodist/sql.html(gracefulv<version>fallback when not a git checkout) and shows it in the user menu, so a bug report can be tied to an exact build (#74). NOTICE+THIRD-PARTY-NOTICES.md, and the bundled Chart.js / dagre (MIT) notices are now embedded in the builtdist/sql.html.CONTRIBUTING.mdand thisCHANGELOG.md.- Dependabot configuration for npm + GitHub Actions updates.
0.1.4 - 2026-06-28
- Schema detail pane: removed the "Insert SHOW CREATE" action button; opening a node now rings its card (a double border) and the ring clears on every pane-close path including Esc (#65).
- Code-review follow-ups for the schema/zoom work: extracted
schemaLayout()and afixedAnchor()helper, and the transitive-lineage node cap now counts only linked nodes so a large single database isn't truncated early (#64).
0.1.3 - 2026-06-28
- Whole-database schema graph now draws every table (linked or not), packs the
unlinked tables into a grid below the lineage, and drops the redundant
<db>.prefix from node labels for objects in the focused database (#63).
0.1.2 - 2026-06-28
- Bridged the shipped
html { zoom }across the full-view schema panel and the splitter / detail-pane-resize / popover coordinate math, so the full view fits one screen (the detail-pane DDL was previously pushed off-screen) and drags and popovers track the cursor (#62).
0.1.1 - 2026-06-28
antalya-oauthdemo connection (Google SSO).
- Documentation updates; dropped the inaccurate "zero-dependency" framing (the app bundles two deliberate runtime dependencies).
0.1.0 - 2026-06-28
- Initial release: OAuth-gated (PKCE) single-file SQL browser served from
ClickHouse — SQL editor, sortable results table + chart view, EXPLAIN pipeline
graph, and the schema data-flow graph. Built by esbuild into one
dist/sql.html.