Skip to content

Bring every colour pair to 4.5:1, and keep it there - #283

Merged
fstubner merged 1 commit into
mainfrom
fix/token-contrast
Aug 29, 2026
Merged

fstubner merged 1 commit into
mainfrom
fix/token-contrast

Conversation

@fstubner

Copy link
Copy Markdown
Owner

F-tokens-contrast had been sitting at not_evaluated because the app has no design-tokens.json. Extracting one turned out not to be paperwork: eight tokens fail WCAG AA against surfaces they are actually painted on.

What was measured

Not the theoretical cross-product — these are pairs real CSS rules put together:

Theme Pair Ratio Where you see it
dark --text-muted on --bg-elevated 4.13 the description line under every setting in the Settings dialog
dark --text-muted on --bg-selected 3.36 muted text on a hovered/selected row
dark --red on --bg-selected 3.76 the Down label on a hovered interface row
light --mint on --bg-selected 3.88 the accent, as text — it failed on every light surface
light --text-muted on --bg-body 4.19
light --amber on --bg-base 4.34
light --cyan on --bg-body 4.45

The light theme had drifted furthest, which is what you would expect of the theme nobody looks at. Two of these are in code merged earlier today (#282): the picker's address line is --text-muted on the dialog background, and the red Down label is exactly what a hovered down interface renders.

The fix

Each token moves in lightness only — hue and saturation untouched, so the palette reads the same. Values are the minimum that clears 4.6:1 against every surface, found by search rather than by taste. All 84 foreground×surface combinations now clear 4.5:1.

The guard

scripts/design-tokens.mjs reads tokens.css, checks the cross-product, and generates design-tokens.json. Runs in CI and as npm run test:tokens.

Two decisions worth arguing with:

  • The JSON is generated, never hand-written. tokens.css is the single source of truth; a hand-maintained copy drifts, and a drifted copy is worse than none — the contrast gate then passes against values that are not what ships. The check fails if the committed JSON no longer matches its source. (site/design-tokens.json is hand-written and has this defect; it should get the same treatment separately.)
  • surface-base maps to the tightest surface per theme, not the nominal base one. --bg-base scored --text-muted at 4.54 while --bg-elevated — the Settings dialog's own background — was at 4.13. Mapping the easy surface would have passed the gate with the dialog still unreadable.

The cross-product is checked rather than an audited list of real pairs. An audited list needs updating whenever a rule pairs a new colour with a new surface, and that update is what gets forgotten. It is stricter than reality, currently satisfied, and needs no maintenance — and it is what found --red on --bg-selected, which was not on my hand-written list.

Verification

  • 84 pairs ≥ 4.5:1 across both themes.
  • Sabotage-checked: restoring the old --text-muted makes the guard fail with all three of its dark-theme pairs named, and flags the JSON as out of date. Restored, green again.
  • The frontend checker now reports SHIP — F-tokens-contrast: 4 pair(s) >= 4.5:1 across 2 theme(s), up from not_evaluated.
  • 184 unit tests, tsc exit 0, eslint clean, file-size guard clean, full Tauri render suite green (it asserts themes, and these are colour changes).

Eight tokens failed WCAG AA against surfaces they are actually painted on.
Measured, not inspected:

  dark   --text-muted on --bg-elevated  4.13   settings dialog descriptions
  dark   --text-muted on --bg-selected  3.36
  dark   --red        on --bg-selected  3.76   "Down" on a hovered row
  light  --mint       on --bg-selected  3.88   accent, as text, everywhere
  light  --text-muted on --bg-body      4.19
  light  --amber      on --bg-base      4.34
  light  --cyan       on --bg-body      4.45

The light theme had drifted furthest, which is what you would expect of the
theme nobody looks at. Two of these are in code merged today: the address line
in the picker is --text-muted on the dialog, and the red "Down" label is what a
hovered down interface renders.

Each token moves in lightness only -- hue and saturation are untouched, so the
palette reads the same. The values are the minimum that clears 4.6:1 against
every surface, found by search rather than by taste.

scripts/design-tokens.mjs is the guard. It reads tokens.css, checks the full
foreground x surface cross-product, and generates design-tokens.json for the
frontend checker.

Two decisions worth stating:

- The JSON is generated, never hand-written. tokens.css is the single source of
  truth, and a hand-maintained copy drifts; a drifted copy is worse than none,
  because the contrast gate then passes against values that are not what ships.
  The check fails if the committed JSON no longer matches.
- surface-base maps to the tightest surface per theme, not the nominal base
  one. bg-base scored --text-muted at 4.54 while bg-elevated -- the settings
  dialog's own background -- was at 4.13, so mapping the easy surface would
  have passed the gate with the dialog still unreadable.

The cross-product is checked rather than an audited list of pairs real rules
combine: the audited list needs updating whenever a rule pairs a new colour with
a new surface, and that update is what gets forgotten. It is also what found
--red on --bg-selected, which was not on my list.
@fstubner
fstubner merged commit 6c6a653 into main Aug 29, 2026
15 checks passed
@fstubner
fstubner deleted the fix/token-contrast branch August 29, 2026 03:00
fstubner added a commit that referenced this pull request Aug 29, 2026
The guard I added in #283 passes CI and fails on every Windows working copy.
`.gitattributes` sets `* text=auto`, so `design-tokens.json` is checked out CRLF
here and LF on the Ubuntu runner, while the generator always writes LF. The
byte-for-byte comparison then reports the file as out of date on the maintainer's
own machine and green where nobody is looking.

That is the failure the script's own header argues against, one layer up: a
check that is green in the place it is read and red in the place the work
happens teaches people to ignore it.

Compared normalised now. Verified both directions -- CRLF and LF copies both
pass, and a genuinely drifted value is still caught under both.
fstubner added a commit that referenced this pull request Aug 29, 2026
)

site/design-tokens.json was hand-written -- the exact defect #283 argued
against, on the other surface. The reasoning at the time was that values reached
through --sl-color-text and other indirections are not always the declared one,
so they were measured from a served build instead. That concern was real but did
not apply here: generating the file reproduced eight of its ten hand-measured
values byte for byte, including the whole dark theme and the accent that
resolves through `var(--netscli-accent)`.

The two that changed are deliberate:

- light text-muted #667387 -> #647084. --sl-color-gray-3 is the docs footer,
  built-with row and mobile section labels, much of it at 12px. It had been
  raised once already, to clear 4.5:1 against the page background, but nothing
  checked it against --sl-color-gray-6, the card surface, where it sat at
  4.44:1. It now clears every light surface.
- light surface-base now maps to gray-6 rather than bg, matching the app's
  rule of pinning the gate to the tightest surface rather than the flattering
  one. Mapping bg is what let 4.44:1 go unnoticed.

One script now covers both surfaces rather than duplicating the contrast maths.
Two parser changes were needed for the site: selector-list matching, because
Starlight's tokens live on `html[data-theme='dark'], html[data-theme='dark']
::backdrop {` which an anchored match misses entirely; and one level of var()
resolution, for `--sl-color-accent: var(--netscli-accent)`.

The check now runs in site.yml as well as ci.yml. ci.yml gates it on `code`,
which a site-only pull request sets to false, so the site's own contrast could
otherwise regress with nothing looking at it. It takes milliseconds, so neither
workflow can be the one that forgot.
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