Skip to content

design: token discipline for type, radii, elevation and colour — 195 detector findings to 0 - #433

Merged
BorisTyshkevich merged 8 commits into
mainfrom
design/typography-and-token-discipline
Jul 25, 2026
Merged

design: token discipline for type, radii, elevation and colour — 195 detector findings to 0#433
BorisTyshkevich merged 8 commits into
mainfrom
design/typography-and-token-discipline

Conversation

@BorisTyshkevich

@BorisTyshkevich BorisTyshkevich commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

What & why

A design-system audit of font-scale discipline turned into a token-discipline pass:
the mechanical detector reported 195 findings on src/styles.css; this branch
takes the whole src tree to 0. Three commits, each a self-contained pass —
plus a merge of main and a fourth pass bringing #436's newly-merged Dashboard
surface onto the same system, below.

The root cause was the same every time. DESIGN.md documented a scale — type, radii,
elevation — but the stylesheet had no tokens to hold it, so there was nothing to
drift from. 231 literal font-size declarations had grown into 22 distinct values,
14 radius values sat against a documented four-step scale, and 14 shadows spread
across 11 different black alphas. Meanwhile the repo's 100%-per-file coverage gate
found none of it, because none of it is behaviour: conflict-resolution.ts had a
passing unit test the entire time it shipped with no CSS rule at all.

da4cfa3 — typefaces, type scale, contrast

  • The brand typefaces now actually ship. DESIGN.md has always named Inter and
    JetBrains Mono, but with no @font-face and no CDN allowed (hard rule 4), they
    rendered only for users who happened to have them installed locally — everyone
    else silently got the platform UI face and Menlo/Consolas. Verified by measuring
    against generic-family sentinels in the browser: both were falling back on the
    maintainer's own machine. build/fonts.mjs inlines latin-subset, upright,
    variable-weight woff2 as base64 @font-face sources. unicode-range is kept so
    Cyrillic/CJK cells and the ⌘/↵/→ glyphs keep deferring to the platform font
    instead of rendering tofu.

  • 22 font sizes → 6 interface steps (9 / 10.5 / 11.5 / 12.5 / 14 / 16px) plus a
    document ramp for Read surfaces and two display sizes. Nine font: shorthands
    were hiding literal sizes from the first sweep. Five values sat below the
    documented 11px floor.

    The floor moved deliberately, from 11px to 10.5px. The old 11px floor was
    fiction — 36 declarations sat under it, including a 23-use 10.5px tier. Rather
    than pretend, the ramp documents --text-micro (10.5px) as the real minimum for
    anything read without zooming, and confines --text-nano (9px) to SVG graph
    labels via DESIGN.md's Zoomable-Surface Exception: those surfaces pan and zoom,
    and core/dot-layout.js is measured against that size. So the "five below the
    floor" defect is that the floor was undocumented and unenforced, not that 10.5px
    is too small.

  • Contrast. --fg-faint met no accessibility bar in either theme (2.55:1 light,
    3.10:1 dark at worst) while carrying most of the smallest text in the product.
    Fixing it surfaced a second failure: ClickHouse Blue as text is 4.10:1 on light
    chips and 3.83:1 on dark surfaces, so text now takes --accent-text while fills,
    rings, carets and icons keep --accent, where the 3:1 non-text bar applies.

  • Six surfaces had no CSS rule at all and therefore rendered in browser chrome,
    not in anything plain. The linked-tab conflict chooser (Cross-tab workspace consistency: refresh before write and invalidate stale tabs #343) — the dialog deciding
    whether to overwrite work saved in another tab — had 13.333px Arial buttons with
    2px outset borders and a title visually identical to its description. The
    query-tab marker warning that a linked query changed rendered as an unstyled stray
    !. workspace-not-found had a 32px h1 and a raw #0000EE underlined link.

0abe492 — radii, elevation, palettes

  • 14 radius values → 4 + --r-pill, chosen by what kind of surface a box is.
    border-radius: 18px silently stops being a capsule once the box passes 36px tall,
    and three chips were written that way. .dash-tile was at 10px while DESIGN.md's
    own Dashboard Tiles section said 8px.
  • 14 shadows across 11 black alphas → one token per documented entry. Shadow
    direction and strength exist to explain physical placement; two popovers differing
    for no reason means the vocabulary had stopped communicating.
  • The object-kind and syntax palettes became token families. --kind-* was three
    separate copies of each hex across node fill, edge and legend swatch — free to
    drift apart while claiming to mean the same thing. Five now-redundant
    [data-theme='light'] .sql-* rules deleted.

c03978e — the last seven

Not drift — decisions never made explicitly.

  • KPI cards no longer wear a 3px accent bar. The author's presentation.color
    hint tints the card's own 1px frame instead. My first attempt moved the stripe from
    a border to an inset shadow and the detector flagged it again, correctly — that
    fixed the mitre against the 8px radius and kept the cliché. --kpi-accent now
    defaults to --border rather than --accent, so an un-tinted KPI is neutral and
    ClickHouse Blue is scarce again instead of framing every card in the grid.
  • 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.
  • Two layout-thrashing transitions on high-frequency surfaces: the streaming
    progress strip transitioned width on every progress event, and the resize handles
    transitioned width/height on every pointer cross of every column edge. Both animate
    transform now.
  • The logomark moved to src/ui/brand-logo.ts. Its 26 gradient stops are a
    supplied brand asset — fixed by the brand, outside the palette, never to be
    borrowed for UI chrome. Its own module lets .impeccable/config.json exclude
    exactly that file rather than silencing all ~60 real icons.

Four real bugs the new gate found

Not review — the test found these:

  • .script-cell.rows:hover { background: var(--hover) }--hover has never
    existed, so the hover affordance on a clickable cell resolved to nothing and simply
    never appeared.
  • var(--success, #238636) / var(--danger, #cf222e) referenced tokens that were
    never defined, so KPI delta and export-status colours bypassed the theme
    entirely and quietly failed AA (#238636 is 3.92:1). The contract now forbids hex
    fallbacks outright — they turn a missing token from a visible bug into an invisible
    one — and separately asserts every var() resolves.
  • .cancelled-badge hard-coded #ef4444 on a 12% tint of itself: 3.23:1 in light.
  • Three dead --bg-panel/--bg-sidebar fallback chains — neither token has ever
    been declared anywhere in the repo's history.

The gate

tests/unit/typography-contract.test.js (29 tests) is the point of the branch: every
font-size/font-weight/line-height/radius/shadow must resolve to a token, no two
steps within a ramp may sit closer than 1px
, tokens must match the DESIGN.md
frontmatter, no var(--token, #hex) fallbacks, every var() must resolve, no class the
UI renders may be left unmatched by CSS, token contrast must clear WCAG AA in both
themes, and the inlined fonts must stay inside a byte budget.

The 1px floor is the load-bearing rule: at these sizes a 0.5px step buys ~0.26px of
x-height — below one device pixel at 1× and smaller than the variation between platform
fallback faces. It caught my own first ramp, which still had five 0.5px neighbours.

Semantic and log-level colours are checked against the surfaces they can actually
land on, not the full cross-product — testing every token against every background
reports failures no user can reach (a log level never renders on a chip) and pressures a
correct palette into changing for nothing.

f28d31c + dec4f7e — main merged first, and the gate caught it

#436 merged before this branch, so main now carries the Dashboard main-surface
work (#425). src/styles.css merged cleanly at the text level; the only conflict
was CHANGELOG's ### Added, where both sides are kept. The suite came out of the
merge green apart from three failures in this branch's own gate, reporting
#425's new CSS — code written while the gate did not yet exist. That is the branch
working: 195 → 0 only holds if new code lands on the system too.

What arrived Why it is wrong here Now
.dash-surface-title font-size: 13px; font-weight: 600 half a pixel above --text-body — below the One-Pixel Floor, so a step nobody can see, in a row whose controls sit at --text-label --text-body / --fw-semibold: it steps up by weight, per the Compact Scale Rule
.is-nav-target border-radius: 6px re-rounds its target — a --r-md tile visibly changed shape while highlighted, and the ring mitred against the corner it was pointing at no radius; a box-shadow already follows the target's own
.is-nav-target color-mix(in srgb, var(--accent) 25%, transparent) the ring family is in oklab … 22% — one halo, two spellings, already 3% apart --ring-nav, a fourth member of that family
.dash-surface-toolbar { gap: 8px } never applied: .dash-toolbar has equal specificity and comes later in the file removed; the row is visibly the same toolbar as the filter row beneath it

The title change is the visible one. 13px → 12.5px at the same weight is
imperceptible — which is exactly the argument — but it is worth a look next to the
View | Edit switch.

One defect that was not drift

.is-nav-target painted nothing at all on a flow-band KPI. Those are addressed
through .dash-kpi-member, which is display: contents and therefore generates no
box: getBoundingClientRect() is {0,0,0,0} in Chromium, so a box-shadow on it
renders nothing and navigating to such a tile marked it invisibly. Every
renderDashboard — navigation focus (#425) unit test builds a grafana-grid
workspace, so all of them take the other branch. The rule reaches through to the
card now, exactly as the neighbouring .dash-drop-target rule already has to.

The focus and scroll half of the same root cause — node.focus() and
scrollIntoView on a zero-box element — is #425's behaviour contract and needs its
own unit + Playwright coverage, so it is filed as #438 rather than patched in a
design pass.

Three holes in the gate, which is why all of it shipped

  • The shadow check tested for rgba( only, so a hand-mixed
    color-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 Add dashboard selection state and a full-size dashboard work surface #425 split app.ts into
    app-shell.ts + workbench/workbench-shell.ts — moving the application frame,
    the sidebar and both surface hosts out of its view. The list follows the markup now.

Each is mutation-proven: reverting #425's exact CSS fails the radius and shadow
tests, drifting --ring-nav's halo fails the new one, and an unstyled class in
app-shell.ts fails the coverage one.

One regression this branch already owned

splitters.spec.js had been failing here, unrun, since c03978e moved the
resize indicator off the layout path: a 3px bar held down by scaleX(.34) painted
1.02px and reported 3px to anything reading the computed box. It scales up from
an honest 1px now, and the spec measures the painted extent (layout size × the
transform's own scale) rather than the untransformed box — which is what it always
meant to assert. Verified by reproducing the failure at the pre-merge branch tip.

Verified

npm test 5244 passing, 167 files, 100% statements/lines · tsc --noEmit clean ·
npm run build clean · the mechanical design detector reports 0 across src/.

Playwright 298 passing across chromium/firefox/webkit. The 2 remaining failures
(multi-select.spec.js, WebKit only) reproduce on untouched origin/main in a
clean worktree — WebKit honours Safari's "Press Tab to highlight each item"
preference, which is off by default, so Tab never reaches the button whose focus
outline the spec asserts. Filed as #439; not from this branch.

Checked in real Chromium in both themes: the surface toolbar at desktop and narrow
widths with a long Dashboard name, and the navigation highlight on a grid tile, a
flow-band KPI, and a filter field.

Reviewer notes

  • Artifact cost: 1.5 → 1.7 MiB raw, 465.6 → 557.0 KiB gzip (+19.6%), entirely the
    two self-hosted typefaces. FONT_BYTE_BUDGET in build/fonts.mjs is asserted, so
    adding latin-ext or an italic cut has to be a reviewed edit. Italics are not shipped;
    the browser synthesizes an oblique for the eight secondary uses.
  • @fontsource-variable/* are dev dependencies, same footing as Ajv: no code from
    them ships, the build reads their woff2 files. No new runtime dependency.
  • The KPI card change is the most visible thing here — worth a look on a real
    dashboard before merge.
  • DESIGN.md's body size moved 13px → 12.5px. Collapsing to ≥1px steps forced a
    choice between pulling 13px down or pushing 69 declarations up; I took the denser
    option for a dense tool. Easy to flip.
  • .docs-md-quote was arguably a false positive — a 3px neutral left rule on a
    blockquote is conventional typography, not the pattern the rule targets. Taken to
    1px anyway because it was competing with the admonitions that need noticing.
  • Verified in the browser in both themes at desktop and narrow widths, including the
    previously-unstyled surfaces and the redesigned KPI/admonition treatments. No
    overflow or clipping from the ramp collapse, checked against the deployed app's real
    header markup.
  • Roadmap Roadmap to 1.0.0 #68 tracks feature issues; this is cross-cutting design-system work with no
    track to re-scope. CLAUDE.md hard rule 4 and THIRD-PARTY-NOTICES.md (SIL OFL for
    both faces) updated.

Checklist

  • npm test passes (the per-file coverage gate is non-negotiable) — 5115 tests, 164 files, 100% statements/lines
  • Tests added/updated in the same change as the code
  • npm run build succeeds (single-file dist/sql.html)
  • Layers kept honest: pure logic in src/core/, network in src/net/ (injected fetch), DOM in src/ui/
  • No new runtime dependency (fontsource packages are dev-only; the build inlines their woff2)
  • README / CHANGELOG.md ([Unreleased]) updated if behavior or the deployed surface changed
  • Reconciled affected tracked work (roadmap Roadmap to 1.0.0 #68, the issue body, ADR/CHANGELOG) if this change reshaped it

🤖 Generated with Claude Code

https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF

BorisTyshkevich and others added 4 commits July 25, 2026 13:42
…AA contrast

The type system had no tokens, so there was nothing to drift from. 231 literal
font-size declarations had grown into 22 distinct values — eleven of them inside
a 4px range, five below the documented 11px floor, with 9/9.5, 10/10.5 and
13.5/14/14.5 all coexisting. At these sizes a 0.5px step buys ~0.26px of
x-height: below one device pixel at 1x, and smaller than the variation between
platform fallback faces. Those were not tiers, they were drift.

Fonts now actually ship. DESIGN.md has always named Inter and JetBrains Mono, but
with no @font-face and no CDN allowed (hard rule 4), they rendered only for users
who happened to have them installed — everyone else silently got the platform UI
face and Menlo/Consolas. build/fonts.mjs inlines latin-subset, upright,
variable-weight woff2 as base64 @font-face sources: 88,660 bytes of woff2,
+19.6% gzip on the artifact, still zero third-party requests. unicode-range is
kept so Cyrillic/CJK cells and the glyphs outside the subset keep deferring to
the platform font instead of rendering tofu.

Contrast: --fg-faint met no accessibility bar in either theme (2.55:1 light,
3.10:1 dark at worst) while carrying most of the smallest text in the product.
Fixing it surfaced a second failure — ClickHouse Blue as *text* is 4.10:1 on
light chips and 3.83:1 on dark surfaces — so text takes a new --accent-text
(the palette's existing #005F8A in light, a lifted #2596CC in dark) while fills,
rings, carets and icons keep --accent, where the 3:1 non-text bar applies.

Six surfaces shipped with no CSS rule at all and therefore rendered in browser
chrome, not in anything plain. The linked-tab conflict chooser (#343) — the
dialog deciding whether to overwrite work saved in another tab — had 13.333px
Arial buttons with 2px outset borders and a title visually identical to its
description. The query-tab marker warning that a linked query changed rendered as
an unstyled stray '!'. workspace-not-found had a 32px h1 and a raw #0000EE
underlined link. It had a passing unit test throughout: behaviour coverage cannot
see a missing stylesheet, which is why the contract now has its own gate.

tests/unit/typography-contract.test.js asserts every font-size resolves to a
token, no two steps within a ramp sit closer than 1px, tokens match the DESIGN.md
frontmatter, token contrast clears AA in both themes, no class the UI renders is
left unmatched by CSS, and the inlined fonts stay inside a byte budget.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
… palettes

Same failure mode as the type ramp, three more times over: a documented scale, no
tokens to hold it, and drift filling the gap.

Radii — fourteen values (2/3/4/5/6/7/8/9/10/11/12/16/18/20px) against a documented
four-step scale, with 4/5/6/7px all in play for the same kind of control and
`.dash-tile` at 10px while DESIGN.md's own Dashboard Tiles section said 8px. Now
--r-xs/sm/md/lg chosen by what KIND of surface a box is, plus --r-pill for
capsules: `border-radius: 18px` silently stops being a capsule once the box passes
36px tall, and three chips were written that way.

Elevation — DESIGN.md documents four shadow entries whose whole job is to explain
physical placement. The stylesheet had fourteen distinct shadows across eleven
black alphas, so two popovers could differ for no reason. One token per documented
entry plus --shadow-float for transient surfaces, --scrim for backdrops, and
--ring/--ring-warn/--ring-error so a focus ring states which KIND of state has
focus rather than only that something does.

Colour — three genuine sub-palettes were living as scattered hex. --kind-* and
--role-* (EXPLAIN/schema graph object kinds) were three separate copies of each
hex across node fill, edge and legend swatch, free to drift apart while claiming
to mean the same thing. --sql-* (editor theme) had five [data-theme='light']
rules that are now redundant and deleted. All are documented in DESIGN.md.

Two real bugs fell out of the new gate rather than out of review:

  • `.script-cell.rows:hover { background: var(--hover) }` — --hover has never
    existed, so the hover affordance on a clickable cell resolved to nothing and
    simply never appeared.
  • `var(--success, #238636)` / `var(--danger, #cf222e)` referenced tokens that
    were NEVER DEFINED, so KPI delta and export-status colours bypassed the theme
    entirely and quietly failed AA (#238636 is 3.92:1). A hex fallback turns a
    missing token from a visible bug into an invisible one, so the contract now
    forbids the pattern outright and separately asserts every var() resolves.

Also: `.cancelled-badge` hard-coded #ef4444 on a 12% tint of itself — 3.23:1 in
light theme — now --error-fg at 5.29:1; and three dead --bg-panel/--bg-sidebar
fallback chains removed (neither token has ever been declared).

Semantic and log-level colours are checked against the surfaces they can ACTUALLY
land on, not the full cross-product: testing every token against every background
reports failures no user can reach (a log level never renders on a chip) and
pressures a correct palette into changing for nothing.

Detector on src/styles.css: 149 findings -> 7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
… layout path

The last seven detector findings, none of which were drift — each was a real
design or performance decision that had never been made explicitly.

KPI cards carried `border-top: 3px solid var(--kpi-accent)` on an 8px radius. Two
problems, and the second is the one that matters. The thick top edge mitres
visibly where it tapers into the 1px sides — but more importantly a coloured
stripe across a metric card is the single most over-used shape in the category,
and PRODUCT.md lists decorative dashboards as an anti-reference. The colour is
real (kpi-panel.ts sets --kpi-accent from the query spec's `presentation.color`,
which the schema calls a per-KPI colour hint), so it stays — as a tint on the
card's own 1px frame, which says the same thing without the cliché. --kpi-accent
now defaults to --border instead of --accent: an author who supplies no hint gets
a neutral card, so ClickHouse Blue is scarce again instead of framing every KPI
in the grid.

My first attempt here moved the stripe from a border to `box-shadow: inset 0 3px 0`
and the detector flagged it again — correctly. That fixed the mitre and kept the
pattern, which was solving the wrong half of the problem.

Reference-doc admonitions had the same shape: a 3px left stripe on a rounded,
tinted callout. They now state their variant with a 1px frame in the variant
colour plus a coloured title word — colour paired with text, which is what the
Evidence Rule actually asks for. Blockquotes keep the left-rule convention at
hairline weight, so they stop competing with the admonitions that need noticing.

Two layout-thrashing transitions, both on high-frequency surfaces:
  • the streaming progress strip transitioned `width` on every progress event —
    now full-width with a left origin, scaled by results.ts;
  • the column/row resize handles transitioned width/height 1px->3px on hover,
    and they sit on every column edge — now a fixed 3px bar scaled down at rest.

The Altinity logomark moved to src/ui/brand-logo.ts. Its 26 gradient stops are a
supplied brand asset: fixed by the brand, deliberately outside the DESIGN.md
palette, and never to be borrowed for UI chrome. Giving it its own module lets
.impeccable/config.json exclude exactly that one file from the colour check rather
than silencing all ~60 real icons in icons.ts — and it unclutters a file where one
4 KB string dwarfed sixty small paths.

Detector across the whole src tree: 0 findings (195 before this branch).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
…e fill

Two review blockers, both mine, both in the token pass.

1. The light theme's five --sql-* definitions were self-referential:
   `--sql-keyword: var(--sql-keyword)`. A custom property that references itself
   forms a cycle, and per spec that does NOT fall back to the earlier :root value —
   the computed value becomes invalid, and the .sql-* rules consume these without a
   fallback. So light-mode keywords, functions, strings, numbers and comments lost
   their colour entirely and inherited the editor foreground.

   Cause: the script that pointed hex literals at the new tokens split the file at a
   marker inside :root, so the light theme block was still in its "rewrite" half and
   its own definitions got rewritten into references to themselves. I hit exactly
   this on --ring-error earlier in the same pass, fixed it there, and then verified
   with a grep scoped to :root — which is why this survived.

   Neither existing check could see it: the token WAS defined, and every var()
   reference DID resolve. A cycle needs its own detector, so there is now one, built
   per declaring block (the same name is legitimately redefined in :root and in each
   theme) and catching indirect loops as well as direct self-reference.

2. `.cf-overwrite` used --error-fg as a button FILL with white text. A foreground
   token is tuned to be legible as TEXT on its own plane, so dark theme's #f87171
   gave white 2.77:1 — nowhere near AA, on the button that destroys another tab's
   saved work. New --error-fill is dark enough to carry white in both themes
   (4.98:1 dark, 6.47:1 light) and still clears 3:1 against the dialog behind it so
   the button's own edge stays visible (WCAG 1.4.11). Light reuses #B91C1C, so only
   dark needed a new value.

   The old test asserted white-on-accent and white-on-error-fg for the LIGHT theme
   only, which is precisely how this shipped. Enumerating pairs by hand is what
   failed, so the replacement derives them: it scans the stylesheet for every rule
   putting white on a tokenized background and checks each in BOTH themes, with a
   guard test so a regex that stops matching cannot silently pass on an empty set.

Both new tests were verified to fail on the reintroduced bugs — the cycle detector
on the restored self-reference, and the fill check reporting exactly the 2.77:1 the
review cited — then pass on the fix. Light-mode highlighting confirmed rendering in
the browser: #AF00DB / #795E26 / #A31515 / #098658 / #008000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

Both blockers fixed in 9637b9b. You were right on both, and on both the test gap you named was the reason they shipped.

1. Light-mode syntax cycle. Confirmed and fixed — the five light --sql-* definitions are literals again.

The cause is worth recording: the script that pointed hex literals at the new tokens split the file at a marker inside :root, so the light theme block was still in its "rewrite" half and its own definitions got rewritten into references to themselves. I hit this exact bug on --ring-error earlier in the same pass, fixed it there, and then verified with a grep scoped to :root — which is why this one survived.

Your diagnosis of why the suite missed it is exactly right: the token was defined and every var() did resolve, so the checks either side of it both passed. There is now a cycle detector, built per declaring block (the same name is legitimately redefined in :root and in each theme) and catching indirect loops as well as direct self-reference.

Verified rendering in the browser rather than just in the stylesheet — light mode now resolves #AF00DB / #795E26 / #A31515 / #098658 / #008000.

2. Destructive button in dark mode. Confirmed at your figure — white on #f87171 is 2.77:1.

Took the --error-fill option. A foreground token is tuned to be legible as text on its plane, which is precisely why it fails as a fill; conflating the two roles was the actual mistake. --error-fill is #B91C1C light (reusing the existing error colour, no new hue) and #D32F2F dark: white at 6.47:1 / 4.98:1, and ≥3:1 against --bg, --bg-modal and --bg-editor in both themes so the button's own edge stays visible per 1.4.11.

For the test, enumerating pairs by hand is what failed, so the replacement derives them — it scans the stylesheet for every rule putting white text on a tokenized background and checks each in both themes, plus a guard test so a regex that stops matching can't silently pass on an empty set. That check is what would have caught this.

Both new tests were verified against the bugs, not just written: I reintroduced each defect and confirmed the cycle detector fails on the restored self-reference and the fill check reports exactly white on --error-fg: 2.77:1, then confirmed both pass on the fix.

While in there I audited every other text-on-fill and text-on-tint pair this branch introduced, in both themes — .cf-reload, .cf-confirm, .cf-desc, both .qtab-external states, .cancelled-badge, .capped-badge. .cf-overwrite was the only failure; the rest range 4.84–16.72:1.

Suite: 5120 tests, 164 files, coverage gate intact. Detector still 0 across src.

…load

The CSP blocked both typefaces this branch went to the trouble of inlining.
`deploy/http_handlers.xml` and `deploy/nginx/default.conf.template` both send
`font-src 'self'`, and `'self'` is an origin match — it does not cover the `data:`
scheme, which has to be listed explicitly. The neighbouring `img-src data:` is
there for exactly that reason.

Verified rather than reasoned about: served the real dist/sql.html under the exact
policy string read from http_handlers.xml and got, twice,

  Loading the font 'data:font/woff2;base64,…' violates the following Content
  Security Policy directive: "font-src 'self'". The action has been blocked.

with document.fonts reporting status "error" for both families and both measuring
identical to the generic-family sentinel — i.e. not applied.

What makes this worse than an ordinary missing-config bug is that it fails
invisibly. A blocked @font-face renders no tofu: the stack falls through to
-apple-system / Menlo and produces perfectly acceptable text. That is bit-for-bit
the bug this branch exists to fix, so the deployed app would have gone on rendering
platform fonts, looked entirely correct, and left no evidence but a console
violation. Meanwhile every local check passed — `npm run local`, a static server
and file:// all serve the artifact with no CSP at all, because the policy lives
only in the deploy configs. My earlier "verified in the browser" was true and
worthless: it never went through a server that sends the header.

So the durable fix is the test, not the token. tests/unit/csp-contract.test.js
asserts every URL scheme the BUILT ARTIFACT references is permitted by the
governing directive, in every config that ships a policy, and that those configs
agree on everything but connect-src. It scans the whole artifact rather than just
the stylesheet — the favicon lives in build/template.html and CodeMirror injects a
`background-image: url(data:…)` from the JS bundle, so a CSS-only scan would have
reported a false all-clear. Generalizes past fonts to a future data: image, blob:
worker or remote reference. Verified to fail on the reintroduced bug (`font-src
must list data:`) and on one-config-only drift.

Also from review:

  • No `font-display`. It describes the display timeline for a NETWORK fetch, and
    a data: source has none — the face is present when the inline <style> parses.
    `swap` asserted a load phase that does not exist and requested a flash of
    fallback that cannot happen; the default is both honest and better behaved.
  • The in-binary license metadata is only partial, and the review's hypothesis was
    half right: fontsource's subsetting keeps nameID 0 (Copyright) and nameID 14
    (License Info URL) but drops nameID 13 (License Description). Confirmed with
    fontTools rather than assumed. No compliance gap in the distributed unit,
    though — dist/sql.html already carries the full OFL text and both copyright
    lines in its leading notices comment, which is now asserted so the embedding
    cannot silently regress.
  • Stale filename in build/fonts.mjs (`typography-contract.test.ts` -> `.js`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

All four addressed in 2f84bf2. The blocker was real and I'd verified it the wrong way — details below, including one place your hypothesis was half right.

Blocker: CSP blocked both fonts — confirmed

Not reasoned about, reproduced. Served the real dist/sql.html under the exact policy string read from deploy/http_handlers.xml and got, twice:

Loading the font 'data:font/woff2;base64,…' violates the following
Content Security Policy directive: "font-src 'self'". The action has been blocked.

document.fonts reported status: "error" for both families, and both measured identical to the generic-family sentinel — not applied. After font-src 'self' data:: both loaded, both applied, zero console errors.

You found one instance; there were two. deploy/nginx/default.conf.template:51 carries the same policy with the same font-src 'self'. Fixed both, plus the copy quoted in docs/blog/no-backend-sql-console.html.

Your point about which server is the one that stings, and it's the actual root cause of my bad verification claim. npm run local, a static server, and file:// all serve the artifact with no CSP at all — the policy exists only in the deploy configs. So "verified in the browser in both themes" was true and worthless: it could not have caught this. I've since verified through a server that sends the real header, and the test below removes the need to remember to.

tests/unit/csp-contract.test.js implements your generalization: every URL scheme the built artifact references must be permitted by the governing directive, in every config that ships a policy, plus an assertion that those configs agree on everything but connect-src.

One correction to the suggested scope — scanning the stylesheet isn't enough. The favicon lives in build/template.html and CodeMirror injects a background-image: url(data:…) from the JS bundle, so a CSS-only scan reports a false all-clear. It scans the whole artifact. Verified to fail on the reintroduced bug (font-src must list data:) and on one-config-only drift.

License metadata: checkable, and you were half right

Ran your snippet. Result differs from the prediction in one field:

nameID 0 Copyright nameID 13 License Description nameID 14 License Info URL
Inter present missing presenthttps://openfontlicense.org
JetBrains Mono present missing presenthttps://scripts.sil.org/OFL

So 13 is dropped as you predicted, but 14 survives — the binaries carry a copyright line and a license URL, just not the license prose.

And the distributed unit is already covered: dist/sql.html carries the full OFL text plus both copyright lines in the leading notices comment build.mjs embeds from THIRD-PARTY-NOTICES.md — verified present, and ahead of <style>. Rather than re-subset, I've asserted it, so the embedding can't silently regress. Your reasoning about the artifact being its own unit of distribution is what made that worth pinning down rather than assuming.

Smaller findings

font-display: swap — removed, not changed to block. You're right that it describes a network timeline; on reflection block describes one too. There is no load phase for a data: source, so the default is both the honest statement and the better behaviour (no flash of fallback to ask for). Asserted absent.

Stale filename — fixed, .test.ts.test.js.

The 11px floor — fair, and the PR body now says it outright. The floor moved deliberately to 10.5px: the old 11px floor was fiction, with 36 declarations under it including a 23-use 10.5px tier. The defect was that the floor was undocumented and unenforced, not that 10.5px is too small.

scaleX on the progress fill — checked. .stream-strip > i is display:block; height:100%; background: var(--accent) and nothing in the .stream-strip subtree sets a border, outline, box-shadow or radius; the strip is 2px tall with overflow:hidden. Flat fill, so scaleX distorts nothing.

Suite: 165 files, 5127 tests, coverage gate intact. Detector still 0 across src.

…rictions

DESIGN.md's Zoomable-Surface Exception reserves --text-nano (9px) for SVG text
inside the EXPLAIN and schema graphs — surfaces that pan and zoom, and whose layout
is measured against that size — and states the floor for anything read without
zooming is --text-micro. Two DOM elements were using it anyway:

  • .mnav-badge, the mobile results-count badge, was `font-size: 9px` before the
    ramp collapse, and the mechanical 9px -> --text-nano mapping carried it onto the
    SVG-only tier without anyone deciding;
  • .qtab-external, the !/⌫ conflict-state <span>, I authored at --text-nano in the
    same commit that wrote the rule forbidding it — while DESIGN.md assigns state
    badges to --text-micro and my own rule comment called it a state badge.

Both are now --text-micro. Verified under the 768px breakpoint (the badge rule is
mobile-only, so measuring at desktop width reads a UA default and proves nothing):
14x14 and 15x15 boxes, no overflow, and a three-digit count stays inside the
viewport.

The deeper problem is that the contract could not see this. Its checks verified a
declaration uses *some* --text-* token, which is syntax; a tier whose entire purpose
is "only here" needs the "only here" asserted. So the restrictions are now
mechanical, all three of them rather than just the one reported:

  • --text-nano only on .explain-graph / .schema-graph selectors;
  • --text-mark only on the login lockup, --text-metric* only on the KPI value —
    this is "The Compact Scale Rule" (nothing in the shell exceeds --text-title)
    made enforceable instead of aspirational;
  • the document ramp only on Read surfaces (.md-view, .docs-*, .login-h1), so
    prose sizes cannot leak into operable chrome.

Each verified to fail on a reintroduced violation, naming the offending selector:
`.qtab-external` for the nano rule, `.logo-name` for a doc-ramp leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MJ9X6AZHTmQ15qBujFpcPF
@BorisTyshkevich

Copy link
Copy Markdown
Collaborator Author

Confirmed and fixed in 4811944. You're right, and the second instance has a worse provenance than the first.

Both violations, and how each got there

  • .mnav-badge was font-size: 9px before this branch. The mechanical 9px→token mapping in the ramp collapse carried it onto --text-nano without anyone deciding — the script matched a number, not a role.
  • .qtab-external I authored at --text-nano in the same commit that wrote the rule forbidding it. Worse, the rule comment I put above it calls it "a state badge" — while DESIGN.md assigns state badges to --text-micro. So the code and the doc contradicted each other inside one diff.

Both now --text-micro, as you specified.

I applied it as two in-place edits rather than the combined .qtab-external, .mnav-badge { } rule, so each declaration stays with its component — .mnav-badge lives inside the max-width: 768px block and hoisting it out would have changed when it applies.

Verified under the breakpoint, which matters here: .mnav-badge's only rule is mobile-only, so measuring at desktop width reads the UA <button> default (13.333px Arial) and proves nothing — I hit exactly that on the first attempt. At 500px: .qtab-external 10.5px in a 14×14 box, .mnav-badge 10.5px in 15×15, a three-digit count widening to 27px and still inside the viewport, no overflow on either, and the live bottom nav's real 59M badge legible.

The test gap, generalized

Your diagnosis is the important part: the contract verified that a declaration uses some --text-* token. That's syntax. A tier whose entire purpose is "only here" needs the "only here" asserted, or the token system documents a restriction it cannot hold.

So rather than special-case nano, I made all three documented restrictions mechanical:

  • --text-nano only on .explain-graph / .schema-graph selectors — your requested fix.
  • --text-mark only on the login lockup, --text-metric* only on the KPI value. This is "The Compact Scale Rule" (nothing in the shell exceeds --text-title) turned from aspiration into a gate.
  • The document ramp only on Read surfaces (.md-view, .docs-*, .login-h1), so prose sizes can't leak into operable chrome — the same failure in the opposite direction.

Each verified against a reintroduced violation and each names the offender: .qtab-external for the nano rule, .logo-name for a doc-ramp leak.

Worth noting the pattern across your three reviews, since it's consistent: every finding has been a case where the verification and the thing shipped weren't the same object — a grep scoped to :root while the bug was in a theme block, a browser check through a server that sends no CSP, and now a contract that checks token syntax while the rule was about token semantics. Three tests now cover those three gaps.

Suite: 165 files, 5130 tests, coverage gate intact. Detector still 0 across src.

… branch

#436 merged first, so this branch takes main and keeps both sides of the one
conflicting hunk (CHANGELOG's `### Added`).

`src/styles.css` merged cleanly at the text level and the suite is green apart
from three deliberate failures: the type/radius gate this branch adds now reports
#425's new `.dash-surface-title` (`font-size: 13px; font-weight: 600`) and
`.is-nav-target` (`border-radius: 6px`) — the gate doing exactly its job on code
written while it did not yet exist. The next commit resolves them.
#436 was written before the gate in this branch existed, so its new CSS reached
main untokenized. Four defects, each of a kind this branch already documented.

**Type.** `.dash-surface-title` was `13px/600` — half a pixel above `--text-body`,
a step below the One-Pixel Floor and therefore one nobody can see, in a row whose
`Back to query` and `View | Edit` controls sit at `--text-label`. It steps up by
WEIGHT now (`--text-body`/`--fw-semibold`), per the Compact Scale Rule: a toolbar
is not a heading's own space. Measured side by side in Chromium — the 0.5px is
invisible, the weight is not.

**Radii.** `.is-nav-target` forced `border-radius: 6px` onto whatever it marked,
so a `--r-md` dashboard tile visibly CHANGED SHAPE while highlighted and the ring
mitred against the corner it was pointing at — the same defect this branch retired
from the KPI accent bar. It sets no radius now; a box-shadow already follows the
target's own.

**Elevation.** That highlight mixed its own translucent accent, `in srgb … 25%`,
beside the ring family's `in oklab … 22%` — one halo in two spellings, 3% apart
before anyone had a reason. It is `--ring-nav`, a fourth member of that family,
documented as the one that marks "you were sent here" rather than focus.

**And it painted nothing at all** on a flow-band KPI: those are addressed through
`.dash-kpi-member`, which is `display: contents` and generates no box, so
navigating to one marked it invisibly. The rule reaches through to the card, as
the neighbouring `.dash-drop-target` rule already has to. The focus/scroll half of
that bug is #425's behaviour contract and is filed as #438, not patched here.

Also: a dead `gap: 8px` on `.dash-surface-toolbar`, overridden by `.dash-toolbar`'s
later, equally specific `gap: 10px` since the day it landed.

## The gate had three holes, which is why the above shipped

- The shadow check tested for `rgba(` only, so a hand-mixed
  `color-mix(…, transparent)` sailed through. It now rejects any box-shadow that
  builds its own translucent colour, and a companion test pins every ring halo to
  one colour space at one alpha.
- The unstyled-class check reads a curated file list, and #425 split `app.ts` into
  `app-shell.ts` + `workbench/workbench-shell.ts` — moving the application frame,
  the sidebar and both surface hosts out of its view. The list follows the markup.

Each is mutation-proven: reverting #425's exact CSS fails the radius and shadow
tests, drifting `--ring-nav`'s halo fails the new one, and an unstyled class in
`app-shell.ts` fails the coverage one.

## One unrelated regression this branch already owned

`splitters.spec.js` had been failing here, unrun, since c03978e moved the resize
indicator off the layout path: a `3px` bar held down by `scaleX(.34)` painted
1.02px and reported `3px` to anything reading the computed box. It scales UP from
an honest `1px` now, and the spec measures the painted extent (layout × the
transform's own scale) rather than the untransformed box.

Verified: 5244 unit tests, 100% statements/lines · `tsc --noEmit` clean · build
clean · detector 0 across `src/` · Playwright 298 passing, the 2 remaining WebKit
failures reproducing on untouched `origin/main` (#439). Both themes and the
highlight on tiles, flow-band KPIs and filter fields checked in real Chromium.

Refs #425. Filed #438, #439.
@BorisTyshkevich
BorisTyshkevich merged commit 905fc60 into main Jul 25, 2026
7 checks passed
@BorisTyshkevich
BorisTyshkevich deleted the design/typography-and-token-discipline branch August 6, 2026 15:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant