feat(site): add colorblind-friendly themes for protan/deuter and tritan by jaaydenh · Pull Request #24672 · coder/coder · GitHub
Skip to content

feat(site): add colorblind-friendly themes for protan/deuter and tritan#24672

Draft
jaaydenh wants to merge 3 commits intomainfrom
feat/colorblind-themes
Draft

feat(site): add colorblind-friendly themes for protan/deuter and tritan#24672
jaaydenh wants to merge 3 commits intomainfrom
feat/colorblind-themes

Conversation

@jaaydenh
Copy link
Copy Markdown
Contributor

@jaaydenh jaaydenh commented Apr 23, 2026

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.

Preference ID Purpose
dark-protan-deuter / light-protan-deuter Protanopia & deuteranopia (red/green). Success and additions shift to sky-blue; error and deletions shift to vermilion/orange.
dark-tritan / light-tritan Tritanopia (blue/yellow). Warning shifts from amber to fuchsia; red/green semantic pair is preserved.

The diff panel and every semantic role (success, error, warning, notice, danger) pick up the new palette automatically because they consume the sitewide CSS variables in site/src/index.css. No backend or database change is required: theme_preference is already a free-form text column.

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:

  • Sitewide, not agent-scoped. The diff panel already consumes the sitewide theme via CSS variables. Keeping the change at the user appearance layer also fixes red/green accents in alerts, badges, and build states in one change, and matches how comparable products (e.g. GitHub) ship this feature.
  • No backend change. codersdk/users.go already accepts any theme_preference string, and ThemeProvider now has a shared resolver (resolveThemeName) that maps any persisted value to a concrete theme, tolerating unknowns and the legacy "auto" value.
  • Palette provenance. The protan/deuter palette follows the Okabe–Ito eight-color scheme; the tritan palette follows Paul Tol's "vibrant" scheme. Both are documented in the theme directories.
  • UI deferred. Selecting the new themes is gated on the follow-up PR feat: add theme mode dropdown with sync and single modes #24680 which replaces the flat theme grid with a Theme mode dropdown.

@jaaydenh jaaydenh changed the title feat(site/src/theme): add colorblind-friendly themes for protan/deuter and tritan feat(site/src): add colorblind-friendly themes for protan/deuter and tritan Apr 23, 2026
@jaaydenh jaaydenh force-pushed the feat/colorblind-themes branch from 10c1db5 to 01df266 Compare April 23, 2026 13:14
@jaaydenh jaaydenh changed the title feat(site/src): add colorblind-friendly themes for protan/deuter and tritan feat: add colorblind-friendly themes for protan/deuter and tritan Apr 23, 2026
@jaaydenh jaaydenh changed the title feat: add colorblind-friendly themes for protan/deuter and tritan feat(site): add colorblind-friendly themes for protan/deuter and tritan Apr 23, 2026
@jaaydenh
Copy link
Copy Markdown
Contributor Author

/coder-agents-review

Copy link
Copy Markdown

@coder-agents-review coder-agents-review Bot left a comment

Choose a reason for hiding this comment

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

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\b regex)

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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 as brightness-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-400 never fires. Light-mode amber on dark background.
  • Chart.tsx:17: .dark CSS 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
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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 = {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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";
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

🤖

Comment thread site/src/index.css Outdated
--muted-foreground: var(--content-secondary);
--primary: var(--content-link);
--primary-foreground: var(--surface-primary);
--avatar-lg: 2.5rem;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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)

🤖

Comment thread site/src/contexts/ThemeProvider.tsx
@jaaydenh
Copy link
Copy Markdown
Contributor Author

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge 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/);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge 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.
@jaaydenh jaaydenh force-pushed the feat/colorblind-themes branch from 2c1973e to cf99301 Compare April 24, 2026 08:08
- 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.
Copy link
Copy Markdown
Contributor Author

Coder Agents here. Addressed the review feedback in 3c361f0. Summary by comment:

P1 / DEREM-1 (Codex + coder-agents-review) — fixed.
ThemeProvider and applyEmbedTheme now add the base mode class (dark/light) alongside the concrete theme class, so Tailwind's dark: variant (darkMode: ["selector"]) and any .dark/.light selector-based theming (e.g. ToolIcon, Chart.tsx, TaskStartupWarningButton) keep matching under colorblind variants. Extracted a shared baseModeFor helper in colorblind.ts and added tests that cover every concrete theme in colorblind.test.ts.

P2 / DEREM-2 — fixed.
Removed the overclaimed "Okabe-Ito vermilion" / WCAG AA language from the protan-deuter role comments (dark + light). Comments now describe the palette as a vermilion/orange axis inspired by the CVD-safe scheme, without claiming hex-level provenance or a specific contrast target.

P2 / DEREM-3 — fixed.
Updated the stale ?theme=light|dark comment in AgentEmbedPage to reflect that any concrete theme name (including colorblind variants like dark-tritan) is accepted.

P3 / DEREM-4 (+ Codex P3) — fixed.
The e2e assertion was still .toContain("light") on className, which matches hyphenated variants (light-tritan). Switched to classList.contains("light") so a colorblind light variant cannot satisfy the check. Comment updated.

Nit / DEREM-18 — fixed.
Removed the redundant --avatar-lg, --avatar-default, --avatar-sm, and --radius declarations from .light-protan-deuter and .light-tritan. These already inherit from :root, and the .dark block omits them, so this matches the existing convention.

Nit / DEREM-19 — fixed.
Replaced the empty doc comment on ThemeProvider with a real description of what it does (resolves preference, manages <html> classes including the base mode class, watches OS scheme, skips class manipulation for embeds, delegates to MUI/Emotion providers).

Out of scope on this PR — not changed.

  • DEREM-5, 6, 7, 8, 9, 16, 17 reference AppearanceForm.{tsx,stories.tsx}, THEME_OPTIONS, AUTO_PREFERENCES, ConcreteOption/AutoOption, AutoThemePreviewButton, and the clinical theme labels. None of those exist in this PR. They belong to the follow-up PR feat: add theme mode dropdown with sync and single modes #24680 which adds the Theme mode dropdown UI; I'll address them there.

Intentionally not acted on (architectural choice).

  • DEREM-10: the per-directory layout for colorblind themes is deliberate so the four variants stay structurally symmetric with the base dark/light themes. Compressing them into a spread in theme/index.ts would make the four variants a different shape than the two originals. Happy to revisit if there's a strong preference.
  • DEREM-11: spreading base roles and overriding only the changed ones is nicer, but in practice the base roles are split across several nested objects (danger.disabled, danger.hover, etc.), so a partial spread still has to either hand-merge those subtrees or re-declare them. I'd rather keep the full copy, which is explicit and greppable, than introduce a shallow-spread that risks leaving a stale field. Happy to revisit.

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.
Copy link
Copy Markdown
Contributor Author

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.

1 participant