fix(desktop-next): make coloured text legible on tint hovers, and widen the audit

A second pass over the colour system, checking the surfaces the first audit did
not cover.

The gap was tint hovers. DESIGN.md says a tint carries meaning and takes coloured
text, but the audit only measured text against the four neutral surfaces — so
`--bg-*-tint-hover`, the hardest surface in each coloured family, was never
checked. Four pairings were below target: light danger (Lc 56.6) and warning
(59.2), and dark accent and info (58.4 each). All four passed WCAG AA, which is
why nothing else caught them.

Size step 11 against the tint hover rather than the neutral panel, the same rule
already applied to the neutral ramp: light danger-11 #b91c1c -> #a80008, light
warning-11 #a16207 -> #9c5e00, dark accent-11 and info-11 #8fbaff -> #95c0ff.
Minimum moves in OKLCH holding hue and chroma, so the hues do not shift.

The audit now covers every tint and tint hover in all five families. Verified by
falsification: reverting any one of the four fails the check.

Two findings that are deliberately not changes:

Hairline dividers stay at 1.2-1.8:1. WCAG's 3:1 non-text rule covers boundaries
needed to identify a control or its state, not grouping lines; Radix ships 1.29
and Apple 1.26 for the same job. Raising them would draw the box the fill already
implies. Recorded in DESIGN.md, along with the note that a control's own outline
is a different question when the input layer lands.

`--ring-focus` is an accent against accent: legible on every neutral surface at
3.0-3.5:1, but 1.1:1 on `bg-accent`, where it vanishes. Not yet reachable — the
token is unregistered and unused — so the constraint is documented at the token
instead of being solved speculatively: the component layer that introduces focus
owns a surface-coloured offset, and there is no second ring token.

Glass surfaces were checked by compositing each translucent value over the app
gradient's own stops; all clear the target in both modes, which the relit
neutral ramp is what earns.

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-04 11:07:31 -07:00
parent b7dbe90001
commit f8f07a3b33
3 changed files with 31 additions and 5 deletions

View File

@@ -123,6 +123,15 @@ it is the rule a generated theme is measured against.
every surface it can sit on, in both modes, parsed from `tokens.css` so the
check cannot drift from the tokens. Exceptions live in that script with a
stated reason, which keeps the list short and arguable.
- **A tint's hover is the hardest surface in its family**, so a `text-*` step is
sized against that rather than against the neutral panel. Every failure the
audit found in the coloured families was on a tint-hover, never at rest.
- **Hairline dividers are not held to a contrast target.** WCAG's 3:1 non-text
rule covers boundaries needed to identify a *control* or its state, not
grouping lines. Buzz's borders measure 1.2–1.8:1, which is where Radix and
Apple ship theirs; raising them would draw the box the fill already implies.
When the input layer lands, a control's own outline is a different question and
does need the 3:1 treatment.
## Writing

View File

@@ -71,6 +71,13 @@ const PAIRS = [
["--text-on-info", "--bg-info"],
];
/**
* The families with a tint surface. DESIGN.md: "tint carries a meaning and takes
* coloured text", so each family's `text-*` must be readable on both its tint
* and that tint's hover — the hover state is the harder one and was the gap.
*/
const TINT_FAMILIES = ["accent", "danger", "success", "warning", "info"];
const css = readFileSync(TOKENS, "utf8");
/**
@@ -142,6 +149,10 @@ for (const [mode, map] of Object.entries(modes)) {
for (const surface of SURFACES) check(role, surface);
}
for (const [role, fill] of PAIRS) check(role, fill);
for (const family of TINT_FAMILIES) {
check(`--text-${family}`, `--bg-${family}-tint`);
check(`--text-${family}`, `--bg-${family}-tint-hover`);
}
}
// Guard the maths itself: if these drift, every verdict above is wrong.

View File

@@ -51,7 +51,7 @@
--danger-3: #fecaca;
--danger-8: #f5b5b5;
--danger-9: #dc2626;
--danger-11: #b91c1c;
--danger-11: #a80008;
--success-2: #e8f7ee;
--success-3: #d1f0dd;
@@ -63,7 +63,7 @@
--warning-3: #fde68a;
--warning-8: #f2d98a;
--warning-9: #eab308;
--warning-11: #a16207;
--warning-11: #9c5e00;
--info-2: #e7f1ff;
--info-3: #d0e3ff;
@@ -133,7 +133,7 @@
--accent-3: #1a3358;
--accent-8: #356dbd;
--accent-9: #237eff;
--accent-11: #8fbaff;
--accent-11: #95c0ff;
--danger-2: #3a1515;
--danger-3: #4d1c1c;
@@ -157,7 +157,7 @@
--info-3: #1a3358;
--info-8: #356dbd;
--info-9: #3d87ff;
--info-11: #8fbaff;
--info-11: #95c0ff;
--glass-1: #1c1c1c5c;
--glass-2: #1c1c1c85;
@@ -252,7 +252,13 @@
--bg-panel-glass: var(--glass-2);
--bg-float-glass: var(--glass-3);
/* --- Focus. ---------------------------------------------- */
/* --- Focus. ----------------------------------------------
Legible on every neutral surface (WCAG 3.0–3.5:1), but it is an accent
against accent: on `bg-accent` it measures 1.1:1 and disappears. A focused
primary button therefore needs the ring separated from the fill — an offset
in the surface colour, not a different ring hue, so one focus treatment
works everywhere. Whatever component layer introduces focus owns that
offset; there is no second ring token. */
--ring-focus: var(--accent-8);
/* ==========================================================