Skip to content

fix(a11y): dark theme meets AA on brand-primary surfaces without changing the brand - #55

Merged
LMPrado-DZ23 merged 2 commits into
release/v3.8.55from
fix/dark-theme-brand-contrast
Sep 20, 2026
Merged

LMPrado-DZ23 merged 2 commits into
release/v3.8.55from
fix/dark-theme-brand-contrast

Conversation

@LMPrado-DZ23

@LMPrado-DZ23 LMPrado-DZ23 commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

The residual

The dark theme painted white text on the brand coral — #ffffff on #e54d5e = 3.78:1, under WCAG AA (4.5:1). It was the only violation left in the accessibility matrix, and the gate never saw it: the axe e2e suite only ever measured the light theme (a fresh Playwright context has no persisted theme preference).

Approach — the brand colour is unchanged

--color-primary is still #e54d5e in the dark theme and #b83242 in the light one. Nothing repaints the brand.

What changed is the foreground on brand surfaces, through theme-aware tokens (approach 1 of the brief):

token light dark
--color-primary (brand) #b83242 — unchanged #e54d5e — unchanged
--color-on-primary (new) #ffffff (5.9:1) #000000 (5.6:1 on the fill, 4.7:1 on the bg-primary/90 hover fills)
--color-primary-hover #9f2a38 — unchanged color-mix(in srgb, var(--color-primary) 85%, #ffffff) — derived, lightens
  • The dark hover shade had to lighten instead of darken: black on the old hard-coded #c93d4e is only 4.3:1. It is now derived from --color-primary with color-mix, so it follows the brand instead of drifting from it — the same pattern Phase 9 used for --color-primary-on-tint.
  • No hex and no literal foreground at call sites: 113 lines moved from text-white to text-on-primary. text-primary-foreground (used at a handful of call sites with no token behind it, so it silently inherited the page text colour) is now a real alias of the same token.
  • Colour presets follow the system. A preset or custom colour is written inline on <html> by themeStore and overrides both themes, so the store derives the matching --color-on-primary for that exact colour (white while it meets AA, otherwise black, which then always does) and shades hover away from that text colour.
  • Testing a custom preset surfaced a second, pre-existing gap with the same root cause: --color-primary-on-tint mixes the brand with 80% black/white, a ratio tuned for coral. On another hue it lands under AA on the tints it exists for (green measured 2.95:1 on the sidebar nav item, 2.92:1 on the /dashboard/logs column pills, in the LIGHT theme). themeStore now derives that tint text per theme as well, shading away from the tint until it clears 4.5:1 on every surface × alpha the app paints (sidebar, page, card, subtle × /10, /15, /22) and keeping as much hue as contrast allows; globals.css reads it through a var() fallback so the default preset keeps the exact coral-tuned mixes.

Before / after — axe with the repo's own tag set (wcag2a, wcag2aa, wcag21a, wcag21aa)

Measured by running the product on an isolated dev server + DATA_DIR (OMNIROUTE_BOOTSTRAPPED pinned), theme pinned on both sides (persisted store + prefers-color-scheme, html.dark asserted per page), server warmed twice before each sweep. Totals are violations of any impact; critical/serious in brackets.

Dark theme — BEFORE

page 375 768 1024 1440
/login 0 0 0 0
/dashboard 0 0 0 0
/dashboard/providers 0 1 [1] 1 [1] 0
/dashboard/settings 0 0 0 0
/dashboard/analytics?tab=route-trace 0 0 0 0
/dashboard/logs 1 [1] 1 [1] 1 [1] 1 [1]
/dashboard/onboarding?rerun=1 1 [1] 1 [1] 1 [1] 1 [1]

Every one of them color-contrast, fg=#ffffff bg=#e54d5e ratio=3.78 — e.g. <button class="... bg-primary text-white border-primary">All</button> on /dashboard/logs, the "Get started" button on the wizard, and the count spans inside the active category pill on /dashboard/providers.

Dark theme — AFTER

page 375 768 1024 1440
/login 0 0 0 0
/dashboard 0 0 0 0
/dashboard/providers 0 0 0 0
/dashboard/settings 0 0 0 0
/dashboard/analytics?tab=route-trace 0 0 0 0
/dashboard/logs 0 0 0 0
/dashboard/onboarding?rerun=1 0 0 0 0

Light theme — BEFORE

page 375 768 1024 1440
/login 0 0 0 0
/dashboard 0 0 0 0
/dashboard/providers 0 0 0 0
/dashboard/settings 0 0 0 0
/dashboard/analytics?tab=route-trace 0 0 0 0
/dashboard/logs 0 0 0 0
/dashboard/onboarding?rerun=1 0 0 0 0

Light theme — AFTER

page 375 768 1024 1440
/login 0 0 0 0
/dashboard 0 0 0 0
/dashboard/providers 0 0 0 0
/dashboard/settings 0 0 0 0
/dashboard/analytics?tab=route-trace 0 0 0 0
/dashboard/logs 0 0 0 0
/dashboard/onboarding?rerun=1 0 0 0 0

Custom colour preset — #22c55e, not the default, @1440

page dark BEFORE dark AFTER light BEFORE¹ light AFTER
/login 0 0 0 0
/dashboard 0 0 1 [1] 0
/dashboard/providers 1 [1] (2.27:1) 0 1 [1] 0
/dashboard/settings 0 0 1 [1] 0
/dashboard/analytics?tab=route-trace 0 0 1 [1] 0
/dashboard/logs 1 [1] (2.27:1) 0 1 [15 nodes] 0
/dashboard/onboarding?rerun=1 1 [1] (2.27:1) 0 0 0

¹ measured with the first commit applied, i.e. with --color-primary-on-tint still untouched — these are the pre-existing tint failures (fg=#1b9e4b, 2.92–2.95:1) that the second commit fixes, not a regression from this PR.

Closing the gate that missed it

tests/e2e/a11y.spec.ts now audits both themes for every page (THEMES = ["light", "dark"]): it pins the theme per document (persisted theme store + prefers-color-scheme) and waits for the store to apply html.dark before running axe. The frozen baselines are untouched and still 0 — they now hold for both themes. The nightly job budget goes 30 → 45 min for the doubled axe passes (the suite is nightly-only, so per-PR e2e shard timings are unchanged).

tests/unit/ui/on-primary-contrast.test.ts (new) holds the line statically: the dark --color-primary is still #e54d5e, on-primary meets AA at rest / on hover / over bg-primary/90 on both dark surfaces, each theme's tint text falls back to its own preset override, no call site paints text-white on a brand surface, and the e2e gate keeps auditing dark. tests/unit/ui/theme-store-default-primary.test.tsx covers the preset derivation, including a sweep over the sRGB cube (every colour gets an AA foreground) and AA tint text for every built-in preset in both themes.

Verification

  • 4 typechecks (tsc --noEmit on core / api / dashboard / noimplicit-core): clean.
  • eslint (--max-warnings=0, suppressions file) on all changed files: clean. Prettier: clean.
  • tests/unit/ui (node:test, 189 tests) and the theme-store/providers/webhooks vitest files: pass.
  • check-file-size: only the pre-existing src/lib/db/core.ts (1770 > frozen 1745), untouched here. Complexity ratchets: 2747/3218 and 1235/1437, both under baseline.
  • No baseline was raised and no test was skipped or weakened.

🤖 Generated with Claude Code

…ging the brand

The dark theme painted white text on the brand coral (#ffffff on #e54d5e =
3.78:1, under WCAG AA 4.5:1) — the last violation in the accessibility matrix
(one node on /dashboard/providers, one on /dashboard/logs, one on the onboarding
wizard, at every audited width).

The brand colour is unchanged: `--color-primary` is still #e54d5e in the dark
theme and #b83242 in the light one. What changes is the FOREGROUND on solid
primary surfaces, via a new theme-aware `--color-on-primary` token (white in the
light theme, black in the dark one — 5.6:1 on the coral fill and 4.7:1 on the
bg-primary/90 hover fills). The dark `--color-primary-hover` now lightens from
`--color-primary` with color-mix instead of hard-coding a darker coral, because
black text on the old #c93d4e was 4.3:1.

Call sites use `text-on-primary` instead of `text-white` (113 lines), so no hex
and no literal foreground at call sites, and `text-primary-foreground` is now a
real alias of the same token instead of silently inheriting the page text colour.
themeStore derives the same token for a colour preset or custom colour written
inline on <html>, so a user-chosen colour also lands on an AA foreground (custom
#22c55e went from 2.27:1 to 9.2:1) and its hover shade moves away from the text.

The axe e2e gate now audits every page in BOTH themes; until now a fresh
Playwright context always measured the light theme, which is why this shipped.
Nightly job budget 30 → 45 min for the doubled axe passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 19, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: f04c95fb-66fe-4817-aef7-2e622dbc5310


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Testing a custom preset (#22c55e) surfaced a second, pre-existing gap with the
same root cause: `--color-primary-on-tint` mixes the brand with 80% black (light)
or white (dark), a ratio tuned for the coral brand. Another hue lands under AA on
the very tints Phase 9 introduced the token for — axe measured 2.95:1 on the
light sidebar nav item and 2.92:1 on the /dashboard/logs column pills.

themeStore now derives the tint text for a preset colour per theme, shading it
away from the tint until it clears 4.5:1 on every surface/alpha combination the
app paints (sidebar, page, card, subtle × /10, /15, /22) and keeping as much of
the hue as the contrast allows. globals.css reads those two overrides through a
var() fallback, so the DEFAULT preset keeps the exact coral-tuned mixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@LMPrado-DZ23
LMPrado-DZ23 merged commit 336e1c7 into release/v3.8.55 Sep 20, 2026
15 checks passed
LMPrado-DZ23 pushed a commit that referenced this pull request Sep 20, 2026
…ragments

check:changelog-integrity caught the CHANGELOG-eat pattern on this branch: the
merge of release/v3.8.55 recorded this branch's older CHANGELOG.md, dropping the
New Features header and 8 bullets that landed in #48, #50, #51, #52, #55, #57,
#58 and #59. No commit here ever edits that file, so the fix is to take the base
version wholesale. Verified identical to origin/release/v3.8.55 afterwards, and
the gate now reports "no base bullets lost".

Adds this PR's own entry as fragments instead, which is the convention that
exists precisely to stop this: one feature fragment for the Gemini CLI entry and
the new placeholder, one fix fragment for the two corrected cards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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