Skip to content

fix(ui): raise text contrast to AA at the token tier, and ratchet the rest (#2201) - #2787

Merged
vybe merged 1 commit into
devfrom
fix/2201-contrast-tokens
Sep 22, 2026
Merged

vybe merged 1 commit into
devfrom
fix/2201-contrast-tokens

Conversation

@dolho

@dolho dolho commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

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-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 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:

family 600 / white 700 / white 400 / gray-800
success 3.30 ❌ 5.02 ✅ 8.42 ✅
warning 2.94 ❌ 4.92 ✅ 9.59 ✅
autonomous 3.19 ❌ 5.02 ✅ 8.79 ✅
urgent 3.56 ❌ 5.18 ✅ 6.49 ✅
danger 4.83 ✅ 6.47 ✅ 5.31 ✅
info 5.17 ✅ 6.70 ✅ 5.77 ✅

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-hidden rather 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 (HostTelemetry raw_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, the BaseBadge recipe — 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-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: 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:

light:dashboard 13 → 16   light:settings 6 → 10   light:operations 3 → 5
dark:settings    2 → 5    dark:operations 1 → 3   dark:dashboard 4 → 5   …

body-horizontal-overflow, dashboard-stats-overflow and settings-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

@dolho dolho added the ui PR touches the frontend UI — triggers Playwright e2e tests label Sep 14, 2026
@dolho

dolho commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Review — /review pass

No critical findings. 95 files, but the risk profile is narrow: two exact-string sweeps plus a guard, and I checked the things that could make a sweep of this size go wrong.

Nothing was pinned to the old strings. Grepped tests/, src/frontend/tests/ and e2e/ for text-gray-400 dark:text-gray-500, text-status-success-600, text-status-warning-600 and text-state-autonomous-600 — no consumers. The suite (136 files / 2973 tests) agrees.

The sweeps cannot touch a background. Both replacements are exact pairs beginning text-, so no bg-/border- class could be caught, and no single-half class could be rewritten into a different theme's slot.

Ratchet-neutral by construction. Both swaps preserve class count per file, which is why a 315-occurrence change moves no baseline entry. The one entry that does move (HostTelemetry 7 → 4) is paid for in the same file and named.

The measurements agree with the reporter's. My module independently reproduces 2.80 / 2.54 / 3.19 / 3.30 / 2.13 — the issue's own table — which is the check that matters before acting on any of it.

[I1] The page-level baseline is environment-sensitive (confidence 8/10)

contrast-baseline.json was generated against one instance's data. Counting per treatment rather than per node removes most of the coupling, but not all: a page whose empty state renders different markup than its populated state can produce a different count. Two mitigations already apply — the spec is @interactive (not in the CI smoke tier) and the numbers are small — but the file should say what it was measured against, and a reviewer regenerating it on a different fleet should expect movement that is not a regression. One line in the spec's docblock would do it.

[I2] The "held to zero" rule is stricter than it reads (confidence 7/10)

A page with no baseline entry is held to zero failing treatments. That is the right ratchet rule, but it means adding a page to the PAGES list without regenerating fails the spec rather than recording a starting point — which will read as a broken test to whoever adds the next page. Worth a sentence in the docblock pointing at CONTRAST_BASELINE_UPDATE=1.

[I3] aria-hidden on the meter separators is a behaviour change beyond contrast (confidence 6/10)

Correct — they are punctuation between meters and screen readers should skip them — but it is an accessibility-tree change riding in a contrast PR, and it is what makes the scanner stop reporting them. Both effects are desirable and the comment explains the reasoning; flagging it so the reviewer sees it as a deliberate semantic decision rather than a styling side effect.

Checked and clean

  • The rule is derived, not assumed. The 600 tier fails for the warm families (success 3.30, warning 2.94, autonomous 3.19, urgent 3.56) and passes for the cool ones, which is exactly why stating it per tier rather than per family is the right call — one number nobody has to look up.
  • The unit spec drives the real palette the tokens alias, so a palette bump or token remap fails loudly instead of silently darkening the app.
  • It pins the negative cases too — the two grounds the tertiary tier does not clear (4.39 / 4.06) — so a future palette change that fixes them prompts a revisit instead of leaving a stale prohibition in the contract.
  • The tension is documented rather than resolved by fiat. The remaining tail needs a semantic ink token because adding the missing dark: half at ~1,500 sites would grow the raw-colour ratchet by ~1,500 classes. Writing that into the contract, and declining to bump a baseline to paper over it, is the right disposition — and it is the follow-up the epic now names.
  • Negative control grows all ten page/theme pairs, so the ratchet bites.
  • The PR body states the scope limit the issue itself sets (visible text nodes only; icon-only affordances and focus indicators are a separate audit) rather than implying full coverage.

@dolho
dolho force-pushed the fix/2711-rail-column-reserved branch from 2d51d0a to fc337ce Compare September 14, 2026 14:42
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch from 11a40d5 to 425d958 Compare September 14, 2026 14:42
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch 2 times, most recently from d984a92 to 3fc1555 Compare September 14, 2026 15:58
@dolho
dolho force-pushed the fix/2711-rail-column-reserved branch from 810e9f7 to e5d44ad Compare September 18, 2026 08:48
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch from 3fc1555 to 35557fd Compare September 18, 2026 08:48
@dolho
dolho marked this pull request as ready for review September 18, 2026 08:48
@dolho

dolho commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

/review — post-rebase (stacked on fix/2711-rail-column-reserved, 94 files)

Scope: CLEAN. Plan completion vs #2201: 5 done / 1 partial (mechanical check, see I1).

Rebase note: three conflicts resolved by hand — AgentListPanel.vue and onboarding/ActivationChecklist.vue keep dev's structure with the text-gray-400 dark:text-gray-500 → text-gray-500 dark:text-gray-400 swap applied; the OnboardingWizard.vue hunk (one class swap) was dropped because dev deleted the file (#2715). Verified on the tip: 0 inverted pairs remain anywhere, incl. dev's new #2715 tree (FirstRunRail, FirstRunStepHeader, steps/Step*.vue). Suite 145 files / 3257 passed incl. the raw-colour ratchet (HostTelemetry 7→4 banked in _2201_note).

Critical

None.

Informational

  • [I1] The e2e contrast ratchet's baseline predates the rebase and runs in no gate (8/10) — e2e/contrast-ratchet.spec.js:97 is @interactive; frontend-e2e.yml runs @smoke only and no script/workflow references contrast-ratchet/CONTRAST_BASELINE. The ten counts in e2e/contrast-baseline.json were captured pre-feat(onboarding): browser admin claim, one first-run overlay, credentials without a terminal (trinity-enterprise#580, #581, #582) #2715, so the first manual run will hit "regressed" or "stale ceiling" with no code change. After merge: CONTRAST_BASELINE_UPDATE=1 npx playwright test e2e/contrast-ratchet.spec.js against a fresh stack and commit; consider a nightly hook.
  • [I2] 18 unpaired warm-600 light text sites remain (8/10) — e.g. GitPanel.vue:209 text-status-success-600, :212 text-status-urgent-600, SchedulesPanel.vue:768, TasksPanel.vue:77 — 2.94–3.56:1 on white, the tier the commit measures as failing. The sweep converted the 128 paired occurrences; this second tail is unnamed (only the text-gray-400 tail is). Either swap them (-600 → -700 dark:…-400) or name them in the contract's frozen-tail paragraph.
  • [I3] design-system.md (system of record) untouched (6/10) — the 700/400 rule lands in design-system-contract.md only; mirror one paragraph.

Clean

No backend/auth surface; no v-html; contrast.js exports all consumed by contrast.spec.js; WCAG thresholds named (AA_NORMAL, AA_LARGE); per-file raw-colour counts unchanged by the swap.

@dolho
dolho force-pushed the fix/2711-rail-column-reserved branch from e5d44ad to 1628303 Compare September 18, 2026 09:47
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch from 35557fd to cb11ec6 Compare September 18, 2026 09:47
@dolho
dolho force-pushed the fix/2711-rail-column-reserved branch from 1628303 to 2925833 Compare September 21, 2026 10:17
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch from cb11ec6 to b373897 Compare September 21, 2026 10:17
@dolho
dolho requested a review from vybe September 21, 2026 11:37
@dolho
dolho force-pushed the fix/2711-rail-column-reserved branch from 2925833 to 85111e8 Compare September 21, 2026 11:52
@dolho
dolho force-pushed the fix/2201-contrast-tokens branch from b373897 to 9c2a496 Compare September 21, 2026 11:52
dolho added a commit that referenced this pull request Sep 21, 2026
@vybe
vybe force-pushed the fix/2711-rail-column-reserved branch from 85111e8 to 4347570 Compare September 22, 2026 18:14
… 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
@vybe
vybe changed the base branch from fix/2711-rail-column-reserved to dev September 22, 2026 18:16
@vybe

vybe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

merge-train: rebased onto dev and retargeted — mechanical, three conflicts resolved in the rebase itself (no separate commit). git rebase --onto origin/dev 85111e8c5 (the pre-squash tip of #2785, which is on its own CI run and merges first). Conflicts, all with members that landed today:

  • CompatibilityPanel.vue — dev removed the no_api_key skip paragraph this PR recoloured; took dev (the line is gone).
  • DashboardPanel.vue ×2 — dev (the ent#479 metric tiles) extended the empty-state sentence and added <BoundMetricMark> under the widget description; took dev's content with this PR's text-gray-500 dark:text-gray-400 applied.
  • raw-color-baseline.json — dev's _1925_note / _2202_note (corrected on the train) plus your _2201_note; all count entries merged untouched. A fresh scan-raw-colors.mjs run against the rebased tree needed zero adjustments, so the baseline is exact.

Locally, from inside the rebased tree: rawColorRatchet, loadingGateRatchet, contrast — 36/36. Validation was READY (lane B); follow-ups left to you: docs/memory/design-system.md (system of record) does not yet carry the tier rules the contract gained, and contrast-ratchet.spec.js is @interactive, so the contract's 'ratcheted' wording overstates what CI enforces.

@vybe
vybe force-pushed the fix/2201-contrast-tokens branch from 9c2a496 to 683c4ec Compare September 22, 2026 18:16

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merge-train: validated (lane B), rebased onto dev with three conflicts resolved (announced on the PR), all checks green on the retargeted run.

@vybe
vybe merged commit c390275 into dev Sep 22, 2026
25 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants