fix(ui): raise text contrast to AA at the token tier, and ratchet the rest (#2201) - #2787
Conversation
Review —
|
2d51d0a to
fc337ce
Compare
11a40d5 to
425d958
Compare
d984a92 to
3fc1555
Compare
810e9f7 to
e5d44ad
Compare
3fc1555 to
35557fd
Compare
/review — post-rebase (stacked on
|
e5d44ad to
1628303
Compare
35557fd to
cb11ec6
Compare
1628303 to
2925833
Compare
cb11ec6 to
b373897
Compare
2925833 to
85111e8
Compare
b373897 to
9c2a496
Compare
85111e8 to
4347570
Compare
… rest (#2201) Several recurring treatments fell below WCAG AA. Fixed at the tier, not per component, and the tiers are now measured rather than asserted in prose. **The inverted tertiary pair.** `text-gray-400 dark:text-gray-500` is below AA on BOTH sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own ladder written backwards: light tertiary is gray-500, dark tertiary gray-400, which is what 890 other call sites already use. 187 occurrences across 62 files swapped. The class COUNT per file is unchanged, so the raw-colour ratchet does not move. **The warm families' light text tier.** On a light surface the 600 tier fails for success (3.30), warning (2.94), autonomous (3.19) and urgent (3.56) while the cool families pass (danger 4.83, info 5.17, purple 5.38, primary 6.29) — so the rule is per TIER, not per family: light text is 700 (4.92–7.90 across all nine), dark text is 400 (4.92–9.59 on gray-800). 128 paired occurrences across 52 files. **The Operations count badge.** White ink on a 500 solid is 2.80:1 (urgent) and 3.76:1 (danger). Now urgent-700 (5.18) and danger-600 (4.83). It is small, high-salience and carries a number someone is meant to read, so AA-normal is the bar rather than the 3:1 large-text allowance. **Host telemetry percentages** are read as numbers, so they are text: the 500 tier they used measures 2.28 (success) through 3.56 (urgent) on white. Raised to 700/400. The meter separators between them are decoration and are now marked `aria-hidden` rather than darkened — styling a dot nobody reads would be the wrong fix — and their one class string is hoisted, which pays for the dark half the no-data placeholder was missing: HostTelemetry raw_gray 7 -> 4. **The mechanical half (AC 6), in two layers.** `utils/contrast.js` is pure WCAG arithmetic — linearized, because a channel-average scan is wrong in both directions, which is why this issue's own first numbers had to be recalculated. `tests/unit/contrast.spec.js` drives it over the REAL Tailwind palette the tokens alias, so a palette bump or a token remap turns red instead of silently darkening the app below AA; it pins both ink ladders, all nine status families at both tiers, the badge recipe, and — deliberately — the two grounds the tertiary tier does NOT clear, so a future palette change that fixes them prompts a revisit instead of leaving a stale prohibition in the doc. `e2e/contrast-ratchet.spec.js` is the page-level layer: distinct failing text treatments per page and theme against a checked-in baseline, counted per TREATMENT rather than per node so the number does not move with how much data an instance holds. A page with no entry is held to zero, and an improvement that is not banked fails as a stale ceiling — the #2605 rules. What it freezes rather than fixes, stated plainly: a long tail of `text-gray-400` written with no `dark:` sibling. Adding the missing half at ~1,500 sites would grow the raw-colour ratchet by ~1,500 raw classes, so the contrast guard and the palette guard pull against each other and the way out is a semantic ink token, not a baseline bump. That is written into the contract beside the measured ladders. Verified in both themes against a local instance. Reverting the sweeps grows the ratchet on all ten page/theme pairs (light dashboard 13 -> 16, settings 6 -> 10, dark settings 2 -> 5), so it bites. Frontend unit suite 136 files / 2973 tests. Fixes #2201 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
|
merge-train: rebased onto
Locally, from inside the rebased tree: rawColorRatchet, loadingGateRatchet, contrast — 36/36. Validation was READY (lane B); follow-ups left to you: |
9c2a496 to
683c4ec
Compare
vybe
left a comment
There was a problem hiding this comment.
merge-train: validated (lane B), rebased onto dev with three conflicts resolved (announced on the PR), all checks green on the retargeted run.
Stacked on #2785 → #2784 → #2783 → #2782 → #2781 → #2780 → #2778. Part of epic #1430.
Fixes #2201
Fixed at the tier, not per component
1. The inverted tertiary pair.
text-gray-400 dark:text-gray-500is below AA on both sides — 2.54:1 on white, 2.13:1 on gray-700 — and it is the contract's own ladder written backwards (light tertiary is gray-500, dark tertiary gray-400, which 890 other call sites already use). 187 occurrences across 62 files. The class count per file is unchanged, so the raw-colour ratchet does not move.2. The warm families' light text tier. The 600 tier fails on a light surface for the warm families and passes for the cool ones:
So the rule is per tier, not per family — light text is 700, dark text is 400. 128 paired occurrences across 52 files.
3. The
99+badge. White on a 500 solid is 2.80:1 (urgent) / 3.76:1 (danger) → urgent-700 (5.18) and danger-600 (4.83). It is small, high-salience and carries a number someone is meant to read, so AA-normal is the bar, not the 3:1 large-text allowance.4. Host telemetry percentages are read as numbers, so they are text; the 500 tier they used is 2.28–3.56 on white. Raised to 700/400. The separators between them are decoration and are now
aria-hiddenrather than darkened — styling a dot nobody reads would be the wrong fix — and hoisting their one class string pays for the dark half the no-data placeholder was missing (HostTelemetryraw_gray 7 → 4).The mechanical half (AC 6), in two layers
utils/contrast.js+tests/unit/contrast.spec.js— pure WCAG arithmetic, linearized, because a channel-average scan is wrong in both directions (which is why this issue's own first numbers had to be recalculated). The spec drives it over the real Tailwind palette the tokens alias, so a palette bump or token remap turns red instead of silently darkening the app. It pins both ink ladders, all nine families at both tiers, theBaseBadgerecipe — and deliberately pins the two grounds the tertiary tier does not clear (gray-500 on gray-100 = 4.39; gray-400 on gray-700 = 4.06), so a future palette change that fixes them prompts a revisit rather than leaving a stale prohibition in the doc.e2e/contrast-ratchet.spec.js+contrast-baseline.json— distinct failing text treatments per page and theme, counted per treatment rather than per node so the number does not move with how much data an instance holds. A page with no entry is held to zero; an improvement that is not banked fails as a stale ceiling (the bug(ci): the raw-colour ratchet is documented as enforced but nothing runs it, and the tree has drifted past its baseline #2605 rules).What it freezes rather than fixes — stated plainly
A long tail of
text-gray-400written with nodark:sibling. Adding the missing half at ~1,500 sites would grow the raw-colour ratchet by ~1,500 raw classes: the contrast guard and the palette guard pull against each other, and the way out is a semantic ink token, not a baseline bump. That is written into the contract beside the measured ladders, so the next person meets the reasoning rather than rediscovering it.Verification
Both themes, live, at 1440px. The ratchet's negative control — reverting the sweeps — grows all ten page/theme pairs:
body-horizontal-overflow,dashboard-stats-overflowandsettings-console-clean(the stack's other gates) all still pass. Frontend unit suite: 136 files, 2973 tests green.Scope note: the issue itself says the scan covered visible text nodes only and that icon-only affordances and focus indicators need their own audit — those are not in here, and the ratchet does not claim them.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ