feat(site): add colorblind-friendly themes for protan/deuter and tritan#24672
feat(site): add colorblind-friendly themes for protan/deuter and tritan#24672
Conversation
10c1db5 to
01df266
Compare
|
/coder-agents-review |
There was a problem hiding this comment.
Solid architecture. The resolver/registry/CSS-variable test triangle is well-constructed: resolveThemeName always returns a key that exists in the themes registry (proven by the cartesian-product test), and every CSS class block declares the full semantic variable set (proven by cssVariables.test.ts). The ThemePreview component scoping via nested <div className={theme}> is a clean way to give each tile accurate CSS variable colors. The radiogroup wrapper is a real a11y improvement.
1 P1, 2 P2, 8 P3, 4 Nit. 4 P4 findings in the inventory but below the inline threshold.
The P1 is a functional breakage: dark colorblind themes do not activate Tailwind's dark: variant, so tool icons in the Agents chat are invisible (near-black on dark background). The fix is small (add dark class alongside the concrete theme class for dark variants).
The P2 findings are a stale embed comment and an unverified Okabe-Ito palette claim in the code comments.
"If a bug routed to the wrong variant, the assertion would silently accept it." (Hisoka, on the
\blight\bregex)
Non-posted findings in inventory: DEREM-12 (P4, extractBlock fragility), DEREM-13 (P4, REQUIRED_VARIABLES coverage gap), DEREM-14 (P4, no embed page test), DEREM-15 (P4, CSS/JS dual representation drift risk, pre-existing).
🤖 This review was automatically generated with Coder Agents.
| return () => { | ||
| if (!root.dataset.embedTheme) { | ||
| root.classList.remove("light", "dark"); | ||
| // Remove every theme class we might have applied so switching |
There was a problem hiding this comment.
P1 [DEREM-1] Dark colorblind themes do not activate Tailwind's dark: variant. darkMode: ["selector"] in tailwind.config.js generates CSS matching the .dark class. When ThemeProvider applies dark-protan-deuter to <html> and does NOT also apply dark, every dark: Tailwind utility is dead.
Concrete breakage:
ToolIcon.tsx:56:dark:invert dark:opacity-[0.65]never fires. Tool icons render asbrightness-0 opacity-[0.35]on a near-black background: black at 35% opacity on dark = invisible.TaskStartupWarningButton.tsx:47:dark:border-amber-600 dark:text-amber-400never fires. Light-mode amber on dark background.Chart.tsx:17:.darkCSS selector for chart theming never matches.
"Icons are black at 35% opacity on a dark background, i.e., nearly invisible. This is the agents chat tool icon display, a primary UI surface for the colorblind themes' target audience." (Pariston)
Fix: when the resolved concrete theme starts with dark, also add the dark class to <html>. After root.classList.add(concreteName), add if (concreteName.startsWith("dark")) root.classList.add("dark") and include "dark" in the cleanup removal. Same logic needed in applyEmbedTheme in AgentEmbedPage.tsx. (Pariston P1, Nami P2)
🤖
|
|
||
| // Protanopia and deuteranopia compress the red/green channel, so semantic | ||
| // "good/bad" pairs that rely on green vs red need a different axis. We | ||
| // base the accents on the Okabe-Ito CVD-safe palette: vermilion |
There was a problem hiding this comment.
P2 [DEREM-2] The comment says "we base the accents on the Okabe-Ito CVD-safe palette: vermilion (orange-500 / #f97316 is close)." The PR description says "Tokens are drawn from the Okabe-Ito CVD-safe palette and tuned against WCAG AA contrast targets."
Neither is accurate. Tailwind orange-500 (#f97316) is not Okabe-Ito vermilion (#D55E00); different hue angle and lightness. No Okabe-Ito hex value appears anywhere in the codebase. No contrast ratio is recorded in any comment, test, or documentation. The WCAG AA claim has zero evidence.
The chosen blue/orange axis is reasonable for protan/deuter distinguishability. The issue is claiming a prestigious palette source and a measurable compliance standard without using or verifying either. Fix: either use actual Okabe-Ito values, or update the comment and PR description to say "inspired by CVD-safe palettes" and remove the WCAG AA claim until contrast ratios are computed and recorded. The same comment appears in lightProtanDeuter/roles.ts:6. (Mafu-san)
🤖
| if (isConcreteThemeName(paramTheme)) { | ||
| applyEmbedTheme(paramTheme); | ||
| } else { | ||
| const prefersDark = window.matchMedia( |
There was a problem hiding this comment.
P2 [DEREM-3] Stale comment. Line 178 reads // (?theme=light|dark) or fall back to prefers-color-scheme. but line 183 validates with isConcreteThemeName(paramTheme), which accepts all six concrete theme names including dark-protan-deuter, light-tritan, etc. An embed consumer reading this comment would believe only light and dark are valid query parameter values.
Fix: update to e.g. // Apply the initial theme from the URL query param (?theme=<concrete theme name>) or fall back to prefers-color-scheme. (Mafu-san)
🤖
|
|
||
| // Make sure the page is actually updated to use the light theme | ||
| // Make sure the page is actually updated to use the light theme. | ||
| // Match as a whole word so a colorblind variant like "light-tritan" |
There was a problem hiding this comment.
P3 [DEREM-4] The comment says "Match as a whole word so a colorblind variant like light-tritan never satisfies this assertion." But \b fires between a word character and the hyphen in light-tritan:
> /\blight\b/.test("light-tritan")
true
The regex is no more selective than the old .toContain("light") for hyphenated variants. The test passes today because ThemeProvider correctly manages classes, not because the regex excludes variants. The comment is a false promise.
Fix: use classList.contains which correctly matches space-delimited CSS class tokens:
expect(await root.evaluate((it) => it.classList.contains("light"))).toBe(true);(Hisoka P3, Mafuuu P3, Meruem P3)
🤖
| // Exercises the data-driven preview grid: every theme option renders | ||
| // with the correct active tile, and colorblind rows show the Beta | ||
| // badge. | ||
| export const DarkSelected: Story = { |
There was a problem hiding this comment.
P3 [DEREM-5] Eight stories render the form with different initial values. None have a play function. No interaction, keyboard navigation, or assertion is tested. The comment on line 25 says "Exercises the data-driven preview grid" but these stories only render visuals.
Project convention (AGENTS.md): "Use Storybook stories for all component and page testing, including visual presentation, user interactions, keyboard navigation, focus management, and accessibility behavior." These stories meet only "visual presentation." Could we add play functions that verify clicking a colorblind tile fires onSubmit with the correct preference string, and that the role="radiogroup" wrapper is keyboard-navigable? (Bisky)
🤖
| @@ -0,0 +1,167 @@ | |||
| import type { Roles } from "../roles"; | |||
There was a problem hiding this comment.
P3 [DEREM-11] Each roles file copies the entire base roles object (~165 lines) when only 2 of 9 roles differ. Measured: protan-deuter changes error and success; tritan changes danger and warning. The other 7 roles are byte-for-byte duplicates of the base.
Spreading the base and overriding the changed entries would reduce each file to ~40 lines and make the semantic intent explicit: "this variant changes only these two roles."
import baseRoles from "../dark/roles";
const roles: Roles = {
...baseRoles,
error: { /* overrides */ },
success: { /* overrides */ },
};(Robin)
🤖
| ]; | ||
|
|
||
| type ConcreteOption = { | ||
| kind: "single"; |
There was a problem hiding this comment.
Nit [DEREM-16] kind: "single" contradicts the "concrete" vocabulary established across the PR (ConcreteThemeName, CONCRETE_THEMES, isConcreteThemeName, ConcreteOption). The type is named ConcreteOption but its discriminant is "single". Change to kind: "concrete" so the union matches the type name and the rest of the codebase. (Gon)
🤖
| // User-facing string, may contain punctuation. Shown under the | ||
| // preview tile. | ||
| label: string; | ||
| beta?: boolean; |
There was a problem hiding this comment.
Nit [DEREM-17] beta on the data type feeds preview on the component prop (preview={option.beta} at lines 159 and 171). The indirection forces the reader to verify they mean the same thing. Since the component renders <PreviewBadge />, preview is the natural name. Rename beta to preview in ConcreteOption and AutoOption. (Gon)
🤖
| --muted-foreground: var(--content-secondary); | ||
| --primary: var(--content-link); | ||
| --primary-foreground: var(--surface-primary); | ||
| --avatar-lg: 2.5rem; |
There was a problem hiding this comment.
Nit [DEREM-18] .light-protan-deuter and .light-tritan re-declare --avatar-lg, --avatar-default, --avatar-sm, and --radius. Their dark counterparts omit them (matching .dark). Both inherit correctly from :root, so no variable is missing. The inconsistency is cosmetic but could confuse a future contributor. Either all four colorblind blocks should omit them (since :root provides them) or all four should declare them. The .dark block omits them, so the dark variants are correct; remove the redundant declarations from .light-protan-deuter and .light-tritan. (Mafuuu, Zoro)
🤖
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2c1973eda3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| } else { | ||
| root.classList.add(themePreference); | ||
| } | ||
| root.classList.add(concreteName); |
There was a problem hiding this comment.
Keep
.dark mode selector when applying dark variants
Applying only concreteName here means dark colorblind themes set <html class="dark-protan-deuter"> or <html class="dark-tritan"> without the base .dark class. Any styling keyed to .dark then stops matching, including Tailwind dark: utilities (for example ToolIcon.tsx) and selector-based theming like Chart.tsx (THEMES.dark = ".dark"), so users on the new dark variants can get light-mode visual behavior in parts of the UI. Add the base mode class (dark/light) alongside the concrete theme class.
Useful? React with 👍 / 👎.
| // never satisfies this assertion. | ||
| const [root] = await page.$$("html"); | ||
| expect(await root.evaluate((it) => it.className)).toContain("light"); | ||
| expect(await root.evaluate((it) => it.className)).toMatch(/\blight\b/); |
There was a problem hiding this comment.
Use class-token assertion instead of word-boundary regex
The new regex check does not enforce the plain light theme as intended, because \blight\b also matches hyphenated class names like light-tritan and light-protan-deuter (hyphen is a word boundary). This makes the test pass even when a colorblind light variant is active, so it can miss regressions in exact theme persistence. Assert against class tokens (for example classList.contains("light") with exclusivity) rather than a word-boundary substring regex.
Useful? React with 👍 / 👎.
Add four colorblind-friendly theme variants alongside the existing light and dark themes: - `dark-protan-deuter` and `light-protan-deuter`: high contrast for users with red-green colorblindness (protanopia and deuteranopia). - `dark-tritan` and `light-tritan`: high contrast for users with blue-yellow colorblindness (tritanopia). Each variant ships a full theme module (`branding`, `experimental`, `monaco`, `mui`, `roles`) and a CSS class block in `site/src/index.css` that overrides the semantic color variables most affected by colorblindness (diff highlights, content accents, surface, border, and highlight colors). Extract theme resolution into `site/src/theme/colorblind.ts`, which owns the concrete theme registry, the `resolveThemeName` helper used by `ThemeProvider`, and the `isConcreteThemeName` validator used by `AgentEmbedPage`'s postMessage handler. The embed page now accepts every concrete theme and clears every possible theme class on unmount so switching between a colorblind variant and the base theme does not leave a stale class on the root. Add `cssVariables.test.ts`, which asserts that every theme class block in `site/src/index.css` declares the full set of required CSS variables so switching themes cannot leave stale values leaking through from a previous theme. This PR ships the palettes and the resolution machinery but does not yet expose the new themes in the Appearance settings page; the follow-up PR adds the picker UI that lets users select them. Produced with Coder Agents assistance.
2c1973e to
cf99301
Compare
- Apply base mode class (`dark`/`light`) alongside concrete theme class
so Tailwind `dark:` utilities and selector-based theming (e.g.
`Chart.tsx`, `ToolIcon.tsx`) keep matching when a colorblind variant
is active. Extract a shared `baseModeFor` helper and cover every
concrete theme in `colorblind.test.ts`.
- Remove the overclaimed Okabe-Ito / WCAG AA language from the
protan-deuter role comments; describe the palette as inspired by the
CVD-safe scheme instead.
- Update the stale `?theme=light|dark` comment in `AgentEmbedPage` to
reflect that any concrete theme (including colorblind variants) is
accepted.
- Replace the `.toContain("light")` assertion in the user-settings e2e
test with `classList.contains("light")` so a hyphenated colorblind
light variant cannot satisfy the check.
- Drop the redundant `--avatar-*` and `--radius` declarations from
`.light-protan-deuter` and `.light-tritan` (they already inherit from
`:root`), matching the `.dark` convention.
- Fill in the previously empty ThemeProvider doc comment.
Co-authored with Coder Agents.
|
Coder Agents here. Addressed the review feedback in 3c361f0. Summary by comment: P1 / DEREM-1 (Codex + coder-agents-review) — fixed. P2 / DEREM-2 — fixed. P2 / DEREM-3 — fixed. P3 / DEREM-4 (+ Codex P3) — fixed. Nit / DEREM-18 — fixed. Nit / DEREM-19 — fixed. Out of scope on this PR — not changed.
Intentionally not acted on (architectural choice).
Produced with Coder Agents assistance. |
The previous `classList.contains(\"light\")` check passes for any theme whose base mode is light, including colorblind variants such as `light-tritan` (which now set both `light-tritan` and `light` classes since ThemeProvider applies the base mode class alongside the concrete theme class). Assert against the full class-list tokens so only the plain `light` theme satisfies the check. Co-authored with Coder Agents.

Adds four sitewide colorblind-friendly theme palettes alongside the existing
light and dark themes. The palettes retune the red/green and blue/yellow
semantic axes so success/error, warning, and diff additions/deletions
remain distinguishable under the most common color vision deficiencies.
dark-protan-deuter/light-protan-deuterdark-tritan/light-tritanThe diff panel and every semantic role (
success,error,warning,notice,danger) pick up the new palette automatically because they consume the sitewide CSS variables insite/src/index.css. No backend or database change is required:theme_preferenceis already a free-formtextcolumn.The existing
"dark","light", and"auto"preferences are unchanged.This PR ships the palettes and the resolution machinery (
CONCRETE_THEMES,resolveThemeName,isConcreteThemeName, and the ThemeProvider/AgentEmbedPage plumbing). It intentionally does not add UI to select the new themes; the follow-up PR #24680 adds a Theme mode dropdown (Sync with system / Single theme) that exposes every concrete theme, including the four added here.Produced with Coder Agents assistance.
Implementation plan and decision log
Full plan (investigation, file layout, TDD phases, open risks) is attached to the chat that produced this PR.
Key decisions:
codersdk/users.goalready accepts anytheme_preferencestring, andThemeProvidernow has a shared resolver (resolveThemeName) that maps any persisted value to a concrete theme, tolerating unknowns and the legacy"auto"value.