From e95068df04d8ee39befd074a6dbabc0421f0c2a9 Mon Sep 17 00:00:00 2001 From: ribdsp <113304041+ribdsp@users.noreply.github.com> Date: Mon, 31 Aug 2026 14:44:54 +0700 Subject: [PATCH] fix: bring faint text up to WCAG AA contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `faint` was #5b626c, which fails 4.5:1 on every ground it is used on: 3.09:1 on `base`, 2.89:1 on `panel`, and 2.65:1 on `raised`, the lightest and so the binding one. The type scale tops out at 15px and nothing using the token is bold, so WCAG's 3:1 large-text allowance applies to none of the 34 sites. #808791 is the smallest lift that clears AA on all eleven grounds the token is read against, including the alpha-composited ones (`panel/40`, `raised/40`, `raised/60`, `warn/5`, `human/5`, `agent/5`), computed by compositing in gamma space the way a browser does. Worst case is now 4.51:1 on `raised`. Being the smallest lift, it is also the value that keeps the most separation from `muted` while passing — though 11 per channel is much less than the 48 the ramp had, so the two secondary tones now read as close to one. Three WCAG 1.4.11 failures go with it, since the token is also 13 borders and 4 fills: the idle status light was 2.89:1, the hover playhead line 2.11:1 and `hover:border-faint` on `raised` 2.65:1, against the 3:1 a UI boundary needs. One site is not fixed and cannot be fixed here. The rejected hypothesis card carries `opacity-60`, which veils text and ground together, so no foreground value can clear AA inside it: `faint` reaches 2.61:1 and `muted` — the card's own body text — only 2.90:1. It is an `opacity` problem rather than a token one, and the card keeps a live `promote` button, so 1.4.3's inactive-component exemption does not cover it either. Left for a change that can decide how the card should recede instead. The contrast note in replay-stage.tsx quoted faint's old 3.09:1 as the reason it picked `muted`; that measurement is no longer true, so the note now gives the margin over replayed page content as the reason instead. --- traces/src/components/player/replay-stage.tsx | 10 ++++++---- traces/tailwind.config.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/traces/src/components/player/replay-stage.tsx b/traces/src/components/player/replay-stage.tsx index b6ac074..b5ded9d 100644 --- a/traces/src/components/player/replay-stage.tsx +++ b/traces/src/components/player/replay-stage.tsx @@ -159,10 +159,12 @@ export function ReplayStage() { {recording && viewport ? ( /* 10px, and allowed to be: an instrument readout in the corner of the frame, not text to read. - `muted` rather than `faint`, though — measured at 3.09:1 against `bg-base`, which is below the - 4.5:1 this size needs, and this is the one readout that sits alone on the frame a recording - spends its whole runtime pointed at. `muted` is 6.07:1. Same size, same swap, same reason as the - ruler's tick labels in `timeline.tsx`, which is the precedent rather than a new judgement. + `muted` rather than `faint`, though — this is the one readout that sits alone on the frame a + recording spends its whole runtime pointed at, and it is read against replayed page content as + often as against `bg-base`. Both tones clear the 4.5:1 this size needs now that `faint` has been + raised for AA (`muted` 6.07:1, `faint` 5.26:1 against `bg-base`), so what picks `muted` here is + the margin over an unknown ground rather than the threshold. Same size, same swap, same reason as + the ruler's tick labels in `timeline.tsx`, which is the precedent rather than a new judgement. */
{viewport.width}×{viewport.height} · {Math.round(scale * 100)}% diff --git a/traces/tailwind.config.ts b/traces/tailwind.config.ts index e761cf2..3e7b800 100644 --- a/traces/tailwind.config.ts +++ b/traces/tailwind.config.ts @@ -57,10 +57,19 @@ const config: Config = { strong: '#343b45', }, - // Text. + // Text. `faint` is pinned by contrast rather than by taste: it is used on eleven different + // grounds, and at this value it clears WCAG AA (4.5:1) on all of them. `raised` is the binding + // one, being the lightest, at 4.51:1 — anything dimmer fails there. The 3:1 large-text + // allowance is no help, because `fontSize` below tops out at 15px and nothing here is bold. + // The cost is that it now sits 11 per channel from `muted` instead of 48, so the two read as + // one tone in places; that is the most separation the pair can have with both passing. + // + // One site is still short of AA, and no value here can fix it: the rejected hypothesis card in + // `hypothesis-cards.tsx` carries `opacity-60`, which veils text and ground together, so no + // foreground can outrun it — 2.61:1 for `faint`, and 2.90:1 for `muted` in the same card. ink: '#e6e8eb', muted: '#8b929c', - faint: '#5b626c', + faint: '#808791', // Authorship. These two carry meaning, not decoration: every marker, hypothesis and // activity entry is tinted by who created it. Do not reuse them for anything else.