Skip to content

fix(gui): typography contrast — WCAG AA pass for text-dim consumers + mention chips - #235

Merged
jcast90 merged 2 commits into
jcast90:mainfrom
chrisbremmer:a11y/text-dim-contrast-audit
Jul 11, 2026
Merged

fix(gui): typography contrast — WCAG AA pass for text-dim consumers + mention chips#235
jcast90 merged 2 commits into
jcast90:mainfrom
chrisbremmer:a11y/text-dim-contrast-audit

Conversation

@chrisbremmer

Copy link
Copy Markdown
Contributor

Closes #159. Follow-up to #158 (hover-state contrast — SC 1.4.11). This pass walks every --color-text-dim text consumer and the mention-chip palette per the issue's acceptance criteria.

Summary

  • Per-call-site text-dim audit. 30 CSS selectors + 12 inline style={} blocks using --color-text-dim for text migrated to --color-text-muted (≥6:1 on paper-base). 4 non-text uses retained: pr-state-closed chip dots ×2, .stream-card.closed dot bg, and the .rail-session-card:hover border affordance.

  • Mention-chip fg darkened to clear AA on its soft-pastel bg:

    • --color-mention-primary-fg: #e65a4f#a43c32 (2.83:1 → 5.17:1)
    • --color-mention-human-fg: #2a7a5a#1f6e54 (4.39:1 → 5.22:1)
    • --color-mention-attached-fg: kept, already 4.94:1

    The accent tokens (--color-accent-coral, --color-accent-mint) keep their resting hue for buttons / focus rings / links, where contrast was already fine. Only the chip-specific fg tokens diverged.

  • @media (prefers-contrast: more) block pushes the muted-text family toward primary for users opting into higher OS contrast (text-muted ≈ 9.4:1, text-dim ≈ 6.6:1, text-on-dark-muted ≈ 10:1).

  • agent_docs/tidewater_handoff/tokens.json synced with the new accessible mention-chip values and a _meta.overrides note documenting that CSS supersedes JSON for any WCAG-darkened token, so re-derivation from the handoff doc doesn't silently revert the a11y fix.

How I chose what to bump vs. keep

Per WCAG 2.1 SC 1.4.3, normal text needs ≥4.5:1; large text (≥18pt regular or ≥14pt bold) needs ≥3:1. text-dim (#8a93a5) on paper-base (#fbf9f4) is 3.1:1 — passes large-text, fails normal-text. Almost every consumer in the codebase is small text (10–14px hints / timestamps / labels), so the migration is overwhelmingly one-way to text-muted. The four retained text-dim uses are all non-text (decorative dots and a hover border), where SC 1.4.11 governs and the 3:1 floor is met.

Found during audit, deferred to follow-ups

  • .channel-header-tier raw-hex pills also fail AA:

    • .tier-bugfix #b26a3a on #fae8d83.54:1
    • .tier-question sky-15%-alpha bg ≈ 3.21:1
    • .tier-feature #2a7a5a on mint-soft ≈ 4.39:1 (borderline)

    These are tier-specific palette, not text-dim drift, so they're out of scope here — happy to open a focused follow-up issue.

  • --color-text-dim no longer applies to text. Renaming to --color-decorative-dim or --color-state-dim would clarify intent for the 4 remaining decorative consumers. Cross-file churn unrelated to contrast — deferred.

Test plan

  • pnpm format:check — passes
  • pnpm typecheck — passes
  • pnpm test — 1098 passed / 28 skipped (no orchestrator tests touch GUI colors)
  • pnpm build — passes
  • cd gui && pnpm build — passes (vite production build)
  • cd gui && pnpm test — 50 passed (mention-chip + component tests; assertions are on classNames, not computed style, so the token swap is correctly invisible to them)
  • Manual visual check on running GUI: channel feed (timestamps, tool messages, PR-link metadata), Settings page (provider form hints, profile table header), channel settings drawer, NewChannel + NewDm modals — every previously-light-grey label should read distinctly darker without breaking the visual hierarchy with text-muted siblings.
  • Manual mention-chip check: render a channel with @repo (primary), @otherrepo (attached), and @user (human) mentions — text should sit comfortably on the soft-pastel background.
  • Manual prefers-contrast: more: macOS System Settings → Accessibility → Display → "Increase contrast" → confirm the muted-text family visibly darkens in the running app.
  • Lighthouse / axe DevTools pass on channel feed + Settings + drawer (per the issue's acceptance criteria).

Notes for review

  • .stream-card.closed .stream-status .dot retains --color-text-dim at opacity: 0.5 deliberately — it's a decorative dot paired with a "done" text label that was bumped to text-muted, so the readable signal is in the label and the dot is supplementary. SC 1.4.11 doesn't require 3:1 for supplementary indicators alongside labeled text.
  • The mention-chip border --color-mention-primary-line is still rgba(230, 90, 79, 0.33) (the resting coral). Now sits next to the darkened brick-red fg — visually intentional (subtle accent border, dark text), but if the line edge reads strangely in review I can darken that too.

🤖 Generated with Claude Code

… mention chips

Closes jcast90#159. Follow-up to PR jcast90#158, which fixed hover-state contrast (1.4.11).
This pass walks every --color-text-dim text consumer and the mention-chip
palette per the issue's acceptance criteria.

What changed

- Per-call-site audit: 30 CSS selectors + 12 inline style={} blocks using
  --color-text-dim for *text* migrated to --color-text-muted (≥6:1 on
  paper-base). 4 non-text uses retained (status dots ×3, hover border).
- Mention-chip fg tokens darkened so chip text clears AA (≥4.5:1) on its
  paired soft-pastel bg:
  - --color-mention-primary-fg: #e65a4f → #a43c32 (2.83:1 → 5.17:1)
  - --color-mention-human-fg:   #2a7a5a → #1f6e54 (4.39:1 → 5.22:1)
  - --color-mention-attached-fg: kept (already 4.94:1)
  The accent tokens (--color-accent-coral / -mint) stay at their resting
  hue for buttons, focus rings, and links where contrast was already fine.
- New @media (prefers-contrast: more) block pushes the muted-text family
  toward primary for users opting into higher OS contrast.
- agent_docs/tidewater_handoff/tokens.json synced with the override values
  and a _meta.overrides note documenting that CSS supersedes JSON for any
  WCAG-darkened token, so re-derivation doesn't silently revert the a11y fix.

Found during audit, not in this PR

- .channel-header-tier raw-hex pills (tier-bugfix 3.54:1, tier-question
  3.21:1, tier-feature 4.39:1) also fail AA. Out of scope here because
  they're tier-specific palette, not text-dim drift — opening a follow-up.
- --color-text-dim now only applies to non-text decorative uses. Renaming
  to --color-decorative-dim would clarify intent; deferred to a follow-up
  to keep this PR focused on contrast.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@chrisbremmer
chrisbremmer requested a review from jcast90 as a code owner May 12, 2026 19:33
@qodo-code-review

Copy link
Copy Markdown

Qodo reviews are paused for this user.

Troubleshooting steps vary by plan Learn more →

On a Teams plan?
Reviews resume once this user has a paid seat and their Git account is linked in Qodo.
Link Git account →

Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center?
These require an Enterprise plan - Contact us
Contact us →

@jcast90 jcast90 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Solid audit — the by-call-site walk + retained decorative uses + chip-fg darkening hit all three classes of contrast failure cleanly, and the WCAG ratios in the inline comments make it auditable later. The _meta.overrides note in tokens.json is the right shape for the CSS-vs-handoff drift problem; just a couple of inline notes on how to make that contract stick. The prefers-contrast: more block is a nice opt-in.

The deferred follow-ups in the PR description (channel-header tier pills, --color-text-dim rename to reflect non-text-only semantics) are both worth their own issues — happy to triage those once #159 closes.

"source": "design/direction-a-base.jsx — window.RELAY_A.A",
"notes": "Two-surface system: deep 'ink' rail (sidebar/workspace rail) + warm 'paper' content. Coral is the only strong accent; reserve it for primary CTAs, active tabs, the primary repo, and selection."
"notes": "Two-surface system: deep 'ink' rail (sidebar/workspace rail) + warm 'paper' content. Coral is the only strong accent; reserve it for primary CTAs, active tabs, the primary repo, and selection.",
"overrides": "Some values below are accessibility-darkened from the original Tidewater palette to clear WCAG 2.1 AA (≥4.5:1 for normal text). When the live CSS in gui/src/styles/tokens.css differs from this file, the CSS value supersedes — re-deriving from this file without re-checking ratios will revert the a11y fix. Current overrides: color.mention.repoPrimaryFg #a43c32 (was #e65a4f, 5.17:1 on -primaryBg); color.mention.humanFg #1f6e54 (was #2a7a5a, 5.22:1 on -humanBg)."

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The CSS-supersedes-JSON contract is the right call given the handoff doc's purpose, but it's fragile — encoded entirely in prose, no enforcement. Two ways to harden it without taking on this PR's scope:

  1. Quick win: a tiny script (scripts/check-token-overrides.mjs) that diff-checks the override-listed tokens between tokens.json and gui/src/styles/tokens.css and fails CI if they re-converge. Wire it into the format-check job. Catches the "someone re-derived from JSON" regression deterministically.
  2. Heavier: generate tokens.css from tokens.json + an explicit tokens.overrides.json, removing the divergence by construction.

Not blocking for this PR — the prose is fine for now — but worth a follow-up issue, since the only thing standing between the next refactor and a silent a11y regression is that someone reads this overrides field before editing.

Comment thread gui/src/styles/tokens.css
--color-mention-primary-bg: #fbe1dd;
--color-mention-primary-fg: #e65a4f;
--color-mention-primary-fg: #a43c32; /* 5.17:1 on -primary-bg */
--color-mention-primary-line: rgba(230, 90, 79, 0.33);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

You called this out in the PR description's "Notes for review" — flagging here so the line itself carries the marker. With --color-mention-primary-fg darkened to #a43c32, the border rgba(230, 90, 79, 0.33) is still keyed to the old coral fg. On the soft-pink bg the resting coral-tinted border now sits next to a much darker brick-red fg, which can read as a two-tone outline (the rgba and the text don't share a hue family any more).

Speculation, no design eyes from me — but two reasonable directions:

  • Re-tint the line to rgba(164, 60, 50, 0.33) (the darkened fg at the same alpha) so the chip reads as one hue family again.
  • Or drop the alpha further (0.18-0.22) so the line becomes barely-there structure rather than an accent in its own right.

Whichever — would be good to do it in this PR rather than as a follow-up, because the visual is currently worse-looking (even if more accessible) than before the patch.

Comment thread gui/src/styles/tokens.css
opt in. */
@media (prefers-contrast: more) {
:root {
--color-text-muted: #3a4254; /* ~9.4:1 on paper-base */

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Good addition. Two notes for the future:

  1. Dark-mode interaction with feat(gui): dark/light mode toggle #162. If the dark-mode toggle PR lands, the [data-theme="dark"] block will need its own parallel @media (prefers-contrast: more) overrides — otherwise dark-mode + high-contrast users get the light-mode-tuned values applied to a dark surface, inverting the intent. Worth dropping a comment here noting that and/or pinging feat(gui): dark/light mode toggle #162 to track. Not blocking — feat(gui): dark/light mode toggle #162 isn't merged yet, just don't want this falling through the cracks if dark lands first.
  2. text-on-dark-muted: #c5cbd9 (~10:1). That's text-primary-bright on dark; arguably too bright if it's used in a glance-only context (timestamps, line metadata) — the user opted into contrast, not strain. If you have a sample dark-rail surface lying around, eyeballing this in prefers-contrast: more before merging would catch the case where 10:1 starts to read as a second primary text tier.

@jcast90
jcast90 merged commit 5504634 into jcast90:main Jul 11, 2026
3 checks passed
jcast90 added a commit that referenced this pull request Jul 11, 2026
…) (#244)

#235 fixed contrast on resting surfaces and is a real improvement, but its
audit only walked resting backgrounds. `text-muted` (#5b6579) clears AA on
paper-base at 5.57:1 — and then `.popover-item:hover` swaps the ground to
paper-hover (#d8cfb0), where it drops to 3.76:1. paper-pressed (#bdb088) takes
it to 2.72:1.

So `.item-sub` fails AA precisely while the user is hovering to read it, which
is the one moment it has to be legible. (#235 still improved that spot a lot —
it was 1.98:1 before.)

Adds --color-text-muted-strong (#3a4254) and applies it to `.item-sub` on hover
and active: 6.45:1 and 4.66:1, both clear AA. It's the same hex the
prefers-contrast tier already uses for muted, so no new color enters the
palette.

Also corrects three annotations so the next audit can trust them:
  - tokens.css: attached-fg is 5.02:1, not 4.94:1; human-fg is 5.17:1, not 5.22:1.
  - tokens.json `_meta.overrides` read "repoPrimaryFg #a43c32 (was #e65a4f,
    5.17:1 on -primaryBg)", which parses as though the OLD value scored 5.17:1.
    It scored 2.85:1; 5.17:1 is the new one. That note exists specifically to
    stop someone re-deriving from the handoff and reverting the a11y fix — as
    written, it invited exactly that. Now spelled `NEW (ratio) replaces OLD
    (ratio)`, with both ratios and a PASS/FAIL on each.

Every ratio recomputed with the WCAG 2.1 relative-luminance formula.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jcast90

jcast90 commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Merged — thanks, this is a solid audit. I recomputed every pair you changed with the WCAG 2.1 relative-luminance formula and your numbers hold up; in two cases you actually understated the improvement (attached-fg is 5.02:1, not 4.94:1).

One gap it missed, fixed in follow-up #244: the audit walked resting backgrounds only. text-muted clears AA on paper-base (5.57:1), but .popover-item:hover swaps the ground to paper-hover #d8cfb0, where it drops to 3.76:1 — so .item-sub still failed AA precisely while the user was hovering to read it. (Still a big win over the 1.98:1 it scored before your change.) #244 adds --color-text-muted-strong for muted text on interaction fills: 6.45:1 on hover, 4.66:1 on pressed.

Also corrected the _meta.overrides note in tokens.json — it read repoPrimaryFg #a43c32 (was #e65a4f, 5.17:1 on -primaryBg), which parses as though the old value scored 5.17:1. It scored 2.85:1. Since that note exists to stop someone re-deriving from the handoff and reverting your fix, it was worth making unambiguous.

Worth an issue separately: pulling text-dim out of every text call site collapses the paper text scale from three tiers to two, so timestamps, placeholders, and hints now render at the same weight as secondary body text. You flagged the text-dim rename as follow-up already — an intermediate token around 4.5–4.8:1 would restore the third tier and still pass AA.

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.

a11y: app-wide typography contrast audit (text-dim on paper-base is 3.1:1)

2 participants