Skip to content

fix: bring faint text up to WCAG AA contrast - #19

Merged
ribdsp merged 1 commit into
mainfrom
fix/faint-text-contrast
Aug 31, 2026
Merged

ribdsp merged 1 commit into
mainfrom
fix/faint-text-contrast

Conversation

@ribdsp

@ribdsp ribdsp commented Aug 31, 2026

Copy link
Copy Markdown
Owner

text-faint was #5b626c, which fails WCAG AA on every ground it is used on. This raises it to
#808791, the smallest value that clears 4.5:1 on all eleven grounds — one site excepted, see the
bottom.

Method

Ratios computed from sRGB relative luminance, with alpha-composited grounds flattened in gamma
space
, which is what a browser does for bg-panel/40 and friends. The existing note in
replay-stage.tsx independently quoted faint at 3.09:1 on bg-base; the calculator reproduced that
figure exactly, which is the check that the method is right.

The 3:1 large-text allowance applies nowhere. fontSize in tailwind.config.ts tops out at
title = 15px, and no site using the token is bold, so every one of the 34 is held to 4.5:1.

Text — before and after, per (colour, ground) pair

Text is faint in every row; only the ground varies. Sizes are from the role-named scale
(micro 10px, label 11px, meta 12px, body 13px).

ground before after sites (size, weight)
base 3.09:1 5.26:1 agent-lane:113 mono 11 normal · agent-lane:144 13 normal (placeholder) · agent-lane:201 12 normal · agent-lane:212 icon 13 · ask-human:174, :178 12 normal · report-draft:147 13 normal (placeholder) · report-draft:188 mono 11 normal (even rows) · report-draft:202 mono 11 normal (aria-hidden) · report-draft:309 12 normal · report-draft:324 11 medium · report-draft:341 11 normal · stage-empty-state:73 12 normal · bisect-trace:327 mono 11 normal · timeline:125 12 normal · section-heading:63 icon 15
panel 2.89:1 4.91:1 page:219 11 normal · activity-feed:81 mono 11 normal · agent-lane:84 mono 11 normal (done chip) · player-controls:139 icon, disabled · player-controls:154 mono 12 normal · recording-picker:357 icon 14, active row · recording-picker:380 mono 11 normal, active row · section-heading:63 icon 15 · webmcp-badge:153 11 normal · webmcp-badge:158 10 normal
raised (binding) 2.65:1 4.51:1 player-controls:225 11 normal · player-controls:281 mono 11, disabled · hypothesis-cards:179 icon 11 · recording-picker:357 icon 14 · recording-picker:380 mono 11 normal
panel/40 on base 3.02:1 5.12:1 report-draft:188 mono 11 normal (odd rows) · hypothesis-cards:124, :189 11 normal (proposed)
panel/40 on warn/5 2.88:1 4.90:1 same two, while a hypothesis is undecided / an agent is waiting
raised/40 on base 2.93:1 4.98:1 stage-empty-state:114, :150 icons 16
raised/60 on base (hover) 2.84:1 4.83:1 agent-lane:212, stage-empty-state:114, :150 on hover
warn/5 on base 2.88:1 4.89:1 agent-lane:183 mono 11 normal (claimed row) · report-draft:147, :309, :324 while awaiting
human/5 on base 2.92:1 4.96:1 hypothesis-cards:124, :189 11 normal (promoted)
human/5 on panel 2.70:1 4.58:1 activity-feed:131 mono 11 normal (your rows)
agent/5 on panel 2.69:1 4.57:1 activity-feed:131 mono 11 normal (agent rows)

Every pair passes. raised is what sets the value: at anything dimmer than #808791 it drops below
4.5:1, and #808791 is the first value that clears it.

Why the smallest lift is also the right one for the ramp

The token lands 11 per channel from muted (#8b929c), where it used to sit 48 below it. The three-tone
text ramp therefore reads as closer to two tones in places. That is a real cost, but it is unavoidable
rather than chosen: any value dark enough to keep the old spacing fails on raised, and any value
lighter collapses the pair further. The minimum lift is simultaneously the maximum separation
available with both tones passing.
Re-spacing muted upward to restore three distinct steps is a
design call, and a separate change.

Non-text uses come along, and three of them were also failing

The token is 34 text uses, 13 borders and 4 fills. Borders and fills are held to 3:1 by WCAG 1.4.11:

use before after
DOT.idle status light, bg-faint on panel (tool-status-banner:99) 2.89:1 fail 4.91:1 ok
hover playhead line, bg-faint/70 on base (timeline:235) 2.11:1 fail 3.16:1 ok
hover:border-faint on raised (7 sites) 2.65:1 fail 4.51:1 ok
bisect probe dot, bg-faint on base (bisect-trace:244) 3.09:1 ok 5.26:1 ok
split handle hover, bg-faint on base (resizable-split:152) 3.09:1 ok 5.26:1 ok
hover:border-faint on base (6 sites) 3.09:1 ok 5.26:1 ok

Sites that do not drive the token

  • player-controls:139 and :281 — disabled:text-faint on a disabled control. WCAG 1.4.3 exempts
    inactive components, so these could not have forced a change. They pass anyway now (4.91:1, 4.51:1).
  • section-heading:63, report-draft:202, and the six icons — aria-hidden glyphs whose meaning is
    carried by adjacent text, so 1.4.11 does not bind them either. Also pass anyway.

Neither group was used to justify the value; raised did that on its own, via readable text.

One site is not fixed, and no value of this token can fix it

hypothesis-cards.tsx:66 gives a rejected hypothesis card bg-panel/20 opacity-60. CSS opacity is a
group veil: it multiplies the text and the card's ground toward the backdrop together, so the ratio
inside is capped no matter what the foreground is.

inside the rejected card ratio
faint before 1.85:1
faint after 2.61:1 — still fails
muted — the card's own hypothesis text 2.90:1 — also fails
ink 6.03:1

Two things follow. First, this is an opacity-60 problem, not a faint problem: the card's body text is
text-muted line-through and fails on its own, so darkening or lightening faint neither causes nor cures
it. Second, WCAG 1.4.3's inactive-component exemption does not apply — I checked, and Verdict is
disabled={active}, so on a rejected card the promote button is still live and titled "Change the record
to promoted." The card is active content, not a disabled control.

Fixing it means deciding how the card should recede without a group veil — explicit dimmer colours, or a
different treatment entirely. That is a design decision about one component's status treatment, so it does
not belong in a commit that moves a shared token, and I have left it. This PR does not bring that one card
to AA
; the config comment records why, so the next person does not read #808791 as a claim about it.

Verification

  • npx tsc --noEmit — exit 0
  • npm run lint — 0 errors, 8 warnings (the same 8 pre-existing since fix: make npm run lint work in a clean clone #16; none in the touched files)
  • npx vitest run — 25 files, 303 tests passed, unchanged from baseline

Note on the second file

replay-stage.tsx is a comment-only edit, and it is here because this change falsified it: it justified
picking muted over faint by quoting faint at 3.09:1, which is no longer its value. The note now gives
the surviving reason — that readout is read against replayed page content, not just bg-base, so it wants
the larger margin — and quotes both current figures.

`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.
@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
traces Ready Ready Preview Aug 31, 2026 7:46am

@ribdsp
ribdsp merged commit 17893ef into main Aug 31, 2026
5 checks passed
@ribdsp
ribdsp deleted the fix/faint-text-contrast branch August 31, 2026 07:50

This branch was successfully deployed

1 active deployment
Preview — e95068df Deployed Aug 31, 2026 by vercel[bot]
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