Skip to content

A11Y-001: the light theme's raw palette text is readable - #991

Merged
NewWorldVenture merged 4 commits into
mainfrom
claude/a11y-001-light-contrast-etzqg7
Oct 10, 2026
Merged

NewWorldVenture merged 4 commits into
mainfrom
claude/a11y-001-light-contrast-etzqg7

Conversation

@NewWorldVenture

@NewWorldVenture NewWorldVenture commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

A11Y-001, the light-theme half of the authenticated sweep from #990.

The defect

The app writes Tailwind's light palette shades straight into class names, about 1,400 times in 257 files: text-emerald-400 224×, text-amber-400 195×, text-rose-400 118×, and so on, plus over 120 hover: and group-hover: forms. They were picked against the dark default theme. The light theme (a user toggle, and system on a light OS) darkened the semantic tokens earlier, but never these classes. On a light surface they read at 1.2 to 2.8:1, against WCAG's 4.5:1 for text and 3:1 for large text and icons. Examples:

  • medications: "0 taken" at 1.86:1;
  • the stress chip "low": 1.27:1;
  • billing: "This month" at 1.64:1.

The fix

One generated block in app/globals.css, in .light only:

  • Each hue's 200/300/400 shade, and its hover: and group-hover: forms, takes that hue's darker shade: the lightest one that keeps 4.5:1 on white, on --bg, on --surface and on the hue's own 500/20 chip tint. That is 800 for the light hues (emerald, amber, yellow, sky, …) and 700 for the darker ones (blue, rose, red, violet, …).
  • Slash variants (text-emerald-300/80) keep their alpha.
  • text-opacity-* still applies.

The rule covers the signed-in app only. It does not apply on marketing pages (.marketing-theme), whose device mock-ups draw dark app screens even in the light theme. There, 22 elements would have dropped from 5.3–13.4:1 to 1.6–2.8:1. It also skips forced-dark subtrees and any surface marked .keep-dark-palette. No measured surface needs that marker yet; it is the escape hatch.

tests/light-theme-palette-text-is-readable.test.ts:

  • computes every rule's ratios from the CSS;
  • fails when a palette class the source uses (in any form) has no rule;
  • checks that slash variants keep their alpha;
  • checks the scope.

Measured

All 246 signed-in, non-admin routes in a disposable Plus household, light theme, plus the 15 public routes. The "after" figures are from a production build of this branch.

main This branch
axe color-contrast, desktop 1280 107 nodes on 36 routes 60 nodes on 14 routes
axe color-contrast, 390 px 141 nodes on 39 routes 96 nodes on 19 routes (40 of them the /display false positive below)
Routes with more failures than on main — 0 at either width
Palette elements on signed-in pages, as rendered (gradients measured at each stop) 322, most below threshold 321 at or above threshold. One is a decorative 30%-opacity icon on a dark violet banner, 1.10 → 1.24
Palette elements on public pages that changed colour — 0 of 542

What is left is not in this rule's scope, and is the next tranche:

  • semantic success / warning / danger / muted text on its own tinted chip, 4.2–4.4:1 (46 of the 60 desktop nodes, mostly the social-accounts and sync status chips);
  • 500/600 palette shades (text-emerald-600 on an emerald tint, 3.3:1);
  • one inline style="color:#fbbf24";
  • read-only inputs;
  • the brand button at 90% opacity.

On /display at 390 px, axe reports 40 nodes at 1.04:1, the same on main and this branch. That is an axe false positive, not a defect. The display paints its own dark backdrop as a fixed -z-10 layer, and the body's light background propagates to the canvas beneath that layer. axe takes the body's colour as the backdrop. A screenshot of main at 390×844 in the light theme shows the dark gradient with readable white text.

Validation: the full unit suite passes (30,685), tsc 0, and eslint on the new test is clean. No component, configuration or dependency change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4


Generated by Claude Code

The app writes Tailwind's light palette shades straight into class names,
about 1,400 times (text-emerald-400, text-amber-300, hover:text-rose-300...).
They were picked against the dark default; in the light theme they read at
1.2 to 2.8:1. The semantic tokens were darkened for light mode earlier; these
never were.

In .light, each hue's 200/300/400 shade and its hover and group-hover forms
take the hue's lightest darker shade that keeps 4.5:1 on white, --bg,
--surface and the hue's own 500/20 chip tint. Slash variants keep their alpha.
The rule is the signed-in app's: marketing pages (which draw dark app screens
in device mock-ups), forced-dark subtrees and .keep-dark-palette surfaces keep
the original shades.

A test computes every rule's ratios from the CSS and fails when a class the
source uses has no rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4
@vercel

vercel Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated
bubaly Ignored Ignored Oct 10, 2026 5:37pm UTC

Request Review

Copy link
Copy Markdown
Owner Author

A11Y-001 / DARK-DISPLAY-PALETTE at120ec8e08f6392f51ae01272cfe3ea2967327f4e: source review finds a conditional /display regression outside the initial light-page samples. DisplayShell has no .dark/.keep-dark-palette boundary, but display-grid.tsx:718–738 uses a dark #0b1020/ambient/photo presentation. Its unavailable-data/timezone status text-amber-200 at778–780 is caught by the new global amber remap; edit-mode text-rose-300 delete control at854–860 sits on bg-black/75 and is caught too. CSS arithmetic predicts amber contrast2.67:1 instead of15.20:1 on #0b1020, and rose roughly1.7:1 instead of5.7:1 on the scrim over a light background. These are source-derived figures, not measured browser results.

Bounded next task for this PR owner: protect intentionally dark display content with an appropriately scoped boundary and add real computed-style coverage of these conditional warning/edit states, alongside light/dark/excluded-page controls. Preserve #985's responsive assistant scroll padding during composition. Do not describe all /display states as unchanged until checked.

Test qualification: the current unit test is source/formula-only, full-alpha RGB on synthetic grounds; it cannot prove computed cascade, alpha compositing, interactions or exclusion inheritance. Also review marker-bearing elements themselves (the exclusion currently targets descendants), and base/hover specificity: combining all arms in :is() gives base rules the maximum hover specificity. No concrete current cross-hue interaction regression is asserted, but include controls before claiming general variant coverage.

Keep broad A11Y-001 open, retain exact-head test evidence and separate candidate from main. No merge/deploy or production configuration changes.

…-correct rules

Review on 120ec8e found three things, all real:
- The wall display (#0b1020 backdrop) and Kitchen Mode (#07070d, full
  screen) stay dark in the light theme, with no boundary: the display's
  edit-mode delete control (rose-300) and status line (amber-200), and
  Kitchen Mode's amber/emerald text, were remapped. Both roots now carry
  .keep-dark-palette.
- The exclusion matched descendants only; it now matches the marked (or
  dark, or marketing) element itself too.
- Base and hover forms shared one :is(), so a base rule carried the hover
  arm's specificity and beat e.g. hover:text-white. Each form is now its own
  rule: a base rule has exactly its Tailwind class's specificity (variants,
  which Tailwind appends later, still win); a hover rule has one class more
  than the variant it replaces.

tests/e2e/light-theme-palette-in-the-browser.spec.ts checks the computed
cascade in Chromium: base, hover, group-hover and slash forms; hover:text-white
still winning; marked element, marked/dark/marketing ancestors; the dark
theme; an ordinary app page; the display's real edit mode (and, removing the
boundary from the live root, the regression it prevents); Kitchen Mode with a
due reminder. The unit test now also pins each rule's specificity. The new
authenticated suite is excluded from CI failure evidence like the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TRY21ZKsFrfB3qtoP972A4

Copy link
Copy Markdown
Owner Author

Re 6098380704: all three are real, and all three are addressed in 249f637. main is merged in at 54edd84, so #985's scroll padding and #988's CI exclusions are preserved.

1. Dark presentations.

  • The display's content root (display-grid.tsx, the text-white root over the #0b1020 / ambient / photo backdrop) now carries .keep-dark-palette.
  • So does Kitchen Mode's full-screen root (briefing-module.tsx, bg-[#07070d]). It has the same problem: amber-200 reminder chips, amber-400 and emerald-400 member status.
  • I also searched for other hard-coded dark or saturated backgrounds that contain these classes. The rest are bg-black/40–60 badges with white text, or backdrops behind light panels, and none needs the marker. The photo lightbox has no palette text.

2. The marked element itself. The exclusion is now :not(:where(.keep-dark-palette, .marketing-theme, .dark, .keep-dark-palette *, .marketing-theme *, .dark *)).

3. Specificity. Each form is now its own rule:

  • A base rule has exactly its Tailwind class's specificity (1 class, via :where(.light)) and wins by coming after it.
  • Tailwind appends every variant (hover:, md:, …) at the end of the stylesheet, so hover:text-white or md:text-white on a remapped element still wins. Measured below.
  • A hover / group-hover rule has one class more than the variant it replaces (.light), because that variant comes later. I found this in the browser: with equal specificity, the hover remap lost.
  • The unit test now pins each selector's specificity (base 1, hover 3, group-hover 4). Removing :where() from one base rule fails it.

Computed-style coverage (tests/e2e/light-theme-palette-in-the-browser.spec.ts, Chromium, built branch), 7/7:

  • Sign-in page, outside the marketing tree, with injected elements whose classes the app actually uses (so Tailwind generated them):
    • base → emerald-800;
    • /80 slash form → rgba(6, 95, 70, 0.8);
    • hover:text-emerald-300 on hover → emerald-800;
    • group-hover:text-violet-200 → violet-700;
    • text-emerald-400 hover:text-white turns white on hover;
    • a marked element, and anything under a marked, dark or marketing-theme ancestor, stays emerald-400;
    • in the dark theme nothing changes.
  • Signed in (disposable household; artifacts excluded like the other authenticated suites; teardown through closeWithoutSnapshot):
    • an ordinary app page is remapped (medications "0 taken" → emerald-800);
    • the display, opened into its real edit mode: the delete control is rose-300. Control: removing .keep-dark-palette from the live root turns it rose-700, which is the regression you predicted. Restoring the class restores rose-300.
    • The status line can no longer be produced with real data, because 0449 holds families.timezone to a real zone. The same element is placed where the display renders it, inside the real root, and stays amber-200.
    • Kitchen Mode, with a due reminder seeded: every remappable class inside renders its own original shade.

Qualification. The unit test is formula-level by design. The browser spec is what proves the cascade, alpha and exclusion inheritance. Interactions beyond hover and group-hover (focus, aria-, data- variants) are not exercised; they are higher-specificity variants that Tailwind appends later, so they win by construction. The light-theme axe and computed-colour figures in the PR body are from 120ec8e; this round changes only the excluded presentations and the cascade order.

Validation at 249f637:

  • full unit suite: 30,915 passed;
  • tsc 0, eslint clean;
  • tests/ci-e2e-artifacts.test.ts: 52/52 with the new suite pinned and in the oracle.

A11Y-001 stays open.


Generated by Claude Code

NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
@NewWorldVenture
NewWorldVenture merged commit a9b7e87 into main Oct 10, 2026
9 checks passed
NewWorldVenture pushed a commit that referenced this pull request Oct 10, 2026
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KP9rt5rVQ9jDMpZp2xBy3K
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.

2 participants