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

This is part 1 to lay the foundation for the theme changes.

Part 2 which adds the UI implementation is in this PR,
https://github.com/coder/coder/pull/24680

-----------------

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;
destructive and deletions shift to vermilion/orange; warning shifts to
fuchsia so warning and destructive states do not collapse onto the same
hue. |
| `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.

<details>
<summary>Implementation plan and decision log</summary>

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 is inspired by
CVD-safe blue/orange palettes and tuned within the existing Tailwind
color scales; the tritan palette keeps red/green semantics intact and
shifts warning to fuchsia. This PR does not claim exact Okabe-Ito or
WCAG AA derivation without recorded contrast data.
- **UI deferred.** Selecting the new themes is gated on the follow-up PR
#24680 which replaces the flat theme grid with a Theme mode dropdown.

</details>
This commit is contained in:
Jaayden Halko
2026-05-04 14:02:58 +01:00
committed by GitHub
parent 6711552f7b
commit 6149fc3619
32 changed files with 1302 additions and 114 deletions
+10 -9
View File
@@ -13,7 +13,7 @@ import { StrictMode } from "react";
import { QueryClient, QueryClientProvider } from "react-query";
import { withRouter } from "storybook-addon-remix-react-router";
import { TooltipProvider } from "../src/components/Tooltip/Tooltip";
import themes from "../src/theme";
import themes, { baseModeFor, isConcreteThemeName } from "../src/theme";
DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark");
@@ -87,20 +87,21 @@ const withQuery: Decorator = (Story, { parameters }) => {
const withTheme: Decorator = (Story, context) => {
const selectedTheme = DecoratorHelpers.pluckThemeFromContext(context);
const { themeOverride } = DecoratorHelpers.useThemeParameters();
const { themeOverride } = DecoratorHelpers.useThemeParameters() ?? {};
const selected = themeOverride || selectedTheme || "dark";
const concreteName = isConcreteThemeName(selected) ? selected : "dark";
const htmlClassName = `${baseModeFor(concreteName)} ${concreteName}`;
// Ensure the correct theme is applied to Tailwind CSS classes by adding the
// theme to the HTML class list. This approach is necessary because Tailwind
// CSS relies on class names to apply styles, and dynamically changing themes
// requires updating the class list accordingly.
document.querySelector("html")?.setAttribute("class", selected);
// concrete theme and base mode to the HTML class list. This mirrors the
// production ThemeProvider so Tailwind's selector-based `dark:` variant keeps
// working in Storybook when a dark colorblind variant is active.
document.querySelector("html")?.setAttribute("class", htmlClassName);
return (
<StrictMode>
<StyledEngineProvider injectFirst>
<MuiThemeProvider theme={themes[selected]}>
<EmotionThemeProvider theme={themes[selected]}>
<MuiThemeProvider theme={themes[concreteName]}>
<EmotionThemeProvider theme={themes[concreteName]}>
<TooltipProvider delayDuration={100}>
<CssBaseline />
<Story />
+19 -6
View File
@@ -1,4 +1,5 @@
import { expect, test } from "@playwright/test";
import { expect, type Page, test } from "@playwright/test";
import { CONCRETE_THEMES } from "#/theme";
import { users } from "../../constants";
import { login } from "../../helpers";
import { beforeCoderTest } from "../../hooks";
@@ -7,6 +8,21 @@ test.beforeEach(({ page }) => {
beforeCoderTest(page);
});
const rootClassNames = async (page: Page) => {
return page.locator("html").evaluate((it) => Array.from(it.classList));
};
// Assert the light theme without rejecting unrelated root classes.
const expectLightThemeClasses = (classes: string[]) => {
const className = "light";
expect(classes).toContain(className);
for (const themeClassName of CONCRETE_THEMES.filter(
(it) => it !== className,
)) {
expect(classes).not.toContain(themeClassName);
}
};
test("adjust user theme preference", async ({ page }) => {
await login(page, users.member);
@@ -15,14 +31,11 @@ test("adjust user theme preference", async ({ page }) => {
await page.getByText("Light", { exact: true }).click();
await expect(page.getByLabel("Light")).toBeChecked();
// Make sure the page is actually updated to use the light theme
const [root] = await page.$$("html");
expect(await root.evaluate((it) => it.className)).toContain("light");
expectLightThemeClasses(await rootClassNames(page));
await page.goto("/", { waitUntil: "domcontentloaded" });
// Make sure the page is still using the light theme after reloading and
// navigating away from the settings page.
const [homeRoot] = await page.$$("html");
expect(await homeRoot.evaluate((it) => it.className)).toContain("light");
expectLightThemeClasses(await rootClassNames(page));
});
+17 -20
View File
@@ -22,11 +22,14 @@ import {
import { useQuery } from "react-query";
import { appearanceSettings } from "#/api/queries/users";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import themes, { DEFAULT_THEME, type Theme } from "#/theme";
import themes, {
baseModeFor,
CONCRETE_THEMES,
DEFAULT_THEME,
resolveThemeName,
type Theme,
} from "#/theme";
/**
*
*/
export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
@@ -56,15 +59,14 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
};
}, [themeQuery]);
// We might not be logged in yet, or the `theme_preference` could be an empty string.
// Prefer JS-fetched value, fall back to server-rendered meta tag, then default.
const themePreference =
// We might not be logged in yet, or the `theme_preference` could be an
// empty string. Prefer the JS-fetched value, fall back to the
// server-rendered meta tag, then to DEFAULT_THEME.
const storedPreference =
appearanceSettingsQuery.data?.theme_preference ||
metadata.userAppearance?.value?.theme_preference ||
DEFAULT_THEME;
// The janky casting here is fine because of the much more type safe fallback
// We need to support `themePreference` being wrong anyway because the database
// value could be anything, like an empty string.
const concreteName = resolveThemeName(storedPreference, preferredColorScheme);
useEffect(() => {
const root = document.documentElement;
@@ -72,22 +74,17 @@ export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
if (root.dataset.embedTheme) {
return;
}
if (themePreference === "auto") {
root.classList.add(preferredColorScheme);
} else {
root.classList.add(themePreference);
}
root.classList.add(concreteName);
root.classList.add(baseModeFor(concreteName));
return () => {
if (!root.dataset.embedTheme) {
root.classList.remove("light", "dark");
root.classList.remove(...CONCRETE_THEMES);
}
};
}, [themePreference, preferredColorScheme]);
}, [concreteName]);
const theme =
themes[themePreference as keyof typeof themes] ??
themes[preferredColorScheme];
const theme = themes[concreteName];
return (
<StyledEngineProvider injectFirst>
+113 -71
View File
@@ -15,6 +15,40 @@
font-display: swap;
}
@layer components {
@media (max-width: 767px) {
/*
Full-width mobile dropdowns. We set a --mobile-dropdown-bottom
custom property on the chat input container so the dropdown
position tracks the actual input box, not a hardcoded offset.
*/
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) {
position: fixed !important;
left: 1rem !important;
width: calc(100vw - 2rem) !important;
min-width: 0 !important;
transform: none !important;
bottom: var(--mobile-dropdown-bottom, 5rem) !important;
top: auto !important;
}
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown-top) {
bottom: auto !important;
top: var(--mobile-dropdown-top, 3.5rem) !important;
}
[data-radix-popper-content-wrapper]:has(
> .mobile-full-width-dropdown-top-below-header
) {
bottom: auto !important;
top: 5rem !important;
}
.mobile-full-width-dropdown {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
}
}
}
@layer base {
:root,
.light {
@@ -154,83 +188,91 @@
--primary: var(--content-link);
--primary-foreground: var(--surface-primary);
}
}
/*
Colorblind-friendly variants. ThemeProvider applies these classes
alongside the base mode class (`dark` or `light`), so unchanged
variables cascade from the base mode and each block only overrides
the semantic colors that shift for colorblind accessibility.
@layer components {
/* Map each stripe variant to a color token so the
pseudo-element rules can stay DRY. */
.navbar-stripe-devel {
--stripe-color: var(--content-warning);
Palette rationale:
- dark-protan-deuter / light-protan-deuter: shift the red/green
success+error axis onto sky-blue (success) + vermilion/orange
(destructive). Warning shifts to fuchsia so it does not collide
with destructive states on the orange axis.
- dark-tritan / light-tritan: keep the red/green success+error
axis intact and move warning from amber to fuchsia because
amber and sky-blue blur together under tritanopia.
*/
.light-protan-deuter {
--content-success: 199 89% 48%;
--content-warning: 322 81% 43%;
--content-destructive: 24 95% 53%;
--surface-destructive: 34 100% 92%;
--surface-green: 201 94% 86%;
--surface-orange: 289 100% 98%;
--surface-red: 34 100% 92%;
--border-success: 199 89% 48%;
--border-green: 201 94% 86%;
--border-warning: 322 81% 60%;
--border-destructive: 24 95% 53%;
--highlight-green: 201 94% 36%;
--highlight-orange: 322 81% 43%;
--highlight-red: 24 95% 42%;
--syntax-string: 24 95% 42%;
--syntax-number: 199 89% 38%;
--git-added: 199 89% 48%;
--git-deleted: 24 95% 53%;
--git-modified: 271 91% 45%;
--git-added-bright: 199 89% 48%;
--git-deleted-bright: 24 95% 53%;
--surface-git-added: 204 94% 94%;
--surface-git-deleted: 33 100% 93%;
}
.navbar-stripe-rc {
--stripe-color: var(--border-sky);
.dark-protan-deuter {
--content-success: 199 82% 67%;
--content-warning: 322 81% 67%;
--content-destructive: 27 96% 67%;
--surface-destructive: 17 75% 15%;
--surface-green: 201 80% 14%;
--surface-orange: 322 70% 15%;
--surface-red: 17 75% 15%;
--border-success: 199 82% 67%;
--border-warning: 322 81% 67%;
--border-destructive: 27 96% 67%;
--border-green: 201 94% 86%;
--highlight-green: 201 94% 86%;
--highlight-orange: 322 81% 67%;
--highlight-red: 27 96% 67%;
--git-added: 199 82% 67%;
--git-deleted: 27 96% 67%;
--git-modified: 271 91% 65%;
--git-added-bright: 199 89% 48%;
--git-deleted-bright: 24 95% 53%;
--surface-git-added: 201 80% 14%;
--surface-git-deleted: 17 75% 15%;
}
/* Thin stripe bars at the top and bottom edges of the
navbar. Using pseudo-elements keeps the stripes out of
the content area so nav links stay readable. */
.navbar-stripe-devel::before,
.navbar-stripe-devel::after,
.navbar-stripe-rc::before,
.navbar-stripe-rc::after {
content: "";
position: absolute;
left: 0;
right: 0;
height: 4px;
background: repeating-linear-gradient(
-45deg,
transparent,
transparent 4px,
hsl(var(--stripe-color) / 0.5) 4px,
hsl(var(--stripe-color) / 0.5) 8px
);
pointer-events: none;
.light-tritan {
--content-warning: 322 81% 43%;
--surface-orange: 289 100% 98%;
--border-warning: 322 81% 60%;
--highlight-orange: 322 81% 43%;
--highlight-magenta: 322, 81%, 43%;
--syntax-boolean: 322 81% 43%;
--git-modified: 322 81% 43%;
}
.navbar-stripe-devel::before,
.navbar-stripe-rc::before {
top: 0;
}
.navbar-stripe-devel::after,
.navbar-stripe-rc::after {
bottom: 0;
}
@media (max-width: 767px) {
/*
* Full-width mobile dropdowns. We set a --mobile-dropdown-bottom
* custom property on the chat input container so the dropdown
* position tracks the actual input box, not a hardcoded offset.
*/
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown) {
position: fixed !important;
left: 1rem !important;
width: calc(100vw - 2rem) !important;
min-width: 0 !important;
transform: none !important;
bottom: var(--mobile-dropdown-bottom, 5rem) !important;
top: auto !important;
}
[data-radix-popper-content-wrapper]:has(> .mobile-full-width-dropdown-top) {
bottom: auto !important;
top: var(--mobile-dropdown-top, 3.5rem) !important;
}
[data-radix-popper-content-wrapper]:has(
> .mobile-full-width-dropdown-top-below-header
) {
bottom: auto !important;
top: 5rem !important;
}
.mobile-full-width-dropdown {
width: 100% !important;
min-width: 0 !important;
max-width: none !important;
}
.dark-tritan {
--content-warning: 322 81% 67%;
--surface-orange: 322 70% 15%;
--surface-magenta: 322 70% 15%;
--border-magenta: 322 81% 72%;
--border-warning: 322 81% 67%;
--highlight-orange: 322 81% 67%;
--highlight-magenta: 322 81% 72%;
--syntax-boolean: 322 81% 67%;
--git-modified: 322 81% 72%;
}
}
@layer base {
* {
@apply border-border;
+16 -7
View File
@@ -8,6 +8,12 @@ import { useAuthContext } from "#/contexts/auth/AuthProvider";
import { ProxyProvider } from "#/contexts/ProxyContext";
import { DashboardProvider } from "#/modules/dashboard/DashboardProvider";
import { permissionChecks } from "#/modules/permissions";
import {
baseModeFor,
CONCRETE_THEMES,
type ConcreteThemeName,
isConcreteThemeName,
} from "#/theme";
import type { AgentsOutletContext } from "./AgentsPage";
import {
bootstrapChatEmbedSession,
@@ -48,7 +54,7 @@ const getBootstrapToken = (data: unknown): string | undefined => {
return token.length > 0 ? token : undefined;
};
const getThemeFromMessage = (data: unknown): "light" | "dark" | undefined => {
const getThemeFromMessage = (data: unknown): ConcreteThemeName | undefined => {
if (typeof data !== "object" || data === null) {
return undefined;
}
@@ -60,7 +66,7 @@ const getThemeFromMessage = (data: unknown): "light" | "dark" | undefined => {
return undefined;
}
const payload = msg.payload as { theme?: unknown };
if (payload.theme !== "light" && payload.theme !== "dark") {
if (!isConcreteThemeName(payload.theme)) {
return undefined;
}
return payload.theme;
@@ -71,13 +77,14 @@ const getThemeFromMessage = (data: unknown): "light" | "dark" | undefined => {
* attribute so ThemeProvider skips its own class manipulation.
* No-ops when the requested theme is already active.
*/
const applyEmbedTheme = (theme: "light" | "dark") => {
const applyEmbedTheme = (theme: ConcreteThemeName) => {
const root = document.documentElement;
if (root.dataset.embedTheme === theme) {
return;
}
root.classList.remove("light", "dark");
root.classList.remove(...CONCRETE_THEMES);
root.classList.add(theme);
root.classList.add(baseModeFor(theme));
root.dataset.embedTheme = theme;
};
@@ -167,12 +174,14 @@ const AgentEmbedPage: FC = () => {
});
// Apply the initial theme from the URL query param
// (?theme=light|dark) or fall back to prefers-color-scheme.
// (?theme=<concrete theme name>) or fall back to
// prefers-color-scheme. Accepts any concrete theme, including
// colorblind-friendly variants such as `dark-tritan`.
// useLayoutEffect runs before paint to prevent a flash.
const [searchParams] = useSearchParams();
useLayoutEffect(() => {
const paramTheme = searchParams.get("theme");
if (paramTheme === "light" || paramTheme === "dark") {
if (isConcreteThemeName(paramTheme)) {
applyEmbedTheme(paramTheme);
} else {
const prefersDark = window.matchMedia(
@@ -181,7 +190,7 @@ const AgentEmbedPage: FC = () => {
applyEmbedTheme(prefersDark ? "dark" : "light");
}
return () => {
document.documentElement.classList.remove("light", "dark");
document.documentElement.classList.remove(...CONCRETE_THEMES);
delete document.documentElement.dataset.embedTheme;
};
}, [searchParams]);
+123
View File
@@ -0,0 +1,123 @@
import themes, {
baseModeFor,
CONCRETE_THEMES,
isConcreteThemeName,
resolveThemeName,
} from ".";
describe("resolveThemeName", () => {
it("returns the stored preference as-is for concrete themes", () => {
expect(resolveThemeName("dark", "light")).toBe("dark");
expect(resolveThemeName("light", "dark")).toBe("light");
expect(resolveThemeName("dark-protan-deuter", "light")).toBe(
"dark-protan-deuter",
);
expect(resolveThemeName("light-protan-deuter", "dark")).toBe(
"light-protan-deuter",
);
expect(resolveThemeName("dark-tritan", "light")).toBe("dark-tritan");
expect(resolveThemeName("light-tritan", "dark")).toBe("light-tritan");
});
it("resolves auto to the OS preference", () => {
expect(resolveThemeName("auto", "dark")).toBe("dark");
expect(resolveThemeName("auto", "light")).toBe("light");
});
it("falls back to the OS scheme for unknown values", () => {
// Empty string is persisted when the user has never set a preference,
// so it must resolve to the OS scheme rather than erroring.
expect(resolveThemeName("", "dark")).toBe("dark");
expect(resolveThemeName("", "light")).toBe("light");
expect(resolveThemeName(undefined, "dark")).toBe("dark");
// Legacy value from an earlier cleanup migration (000260) must still
// resolve safely.
expect(resolveThemeName("darkBlue", "light")).toBe("light");
expect(resolveThemeName("garbage", "dark")).toBe("dark");
});
});
describe("theme registry", () => {
it("contains every concrete theme name", () => {
for (const name of CONCRETE_THEMES) {
expect(themes).toHaveProperty(name);
}
});
it("exports exactly the themes registered in CONCRETE_THEMES", () => {
expect(new Set(Object.keys(themes))).toEqual(new Set(CONCRETE_THEMES));
});
it("always resolves to a theme that exists in the registry", () => {
const preferences: (string | undefined)[] = [
undefined,
"",
"auto",
...CONCRETE_THEMES,
];
for (const pref of preferences) {
for (const scheme of ["dark", "light"] as const) {
const resolved = resolveThemeName(pref, scheme);
expect(themes[resolved]).toBeDefined();
}
}
});
});
describe("isConcreteThemeName", () => {
it("returns true for every concrete theme name", () => {
for (const name of CONCRETE_THEMES) {
expect(isConcreteThemeName(name)).toBe(true);
}
});
it("rejects the auto preference (embeds require a concrete theme)", () => {
expect(isConcreteThemeName("auto")).toBe(false);
});
it("rejects non-string and empty values", () => {
expect(isConcreteThemeName("")).toBe(false);
expect(isConcreteThemeName(undefined)).toBe(false);
expect(isConcreteThemeName(null)).toBe(false);
expect(isConcreteThemeName(42)).toBe(false);
expect(isConcreteThemeName({})).toBe(false);
});
});
describe("baseModeFor", () => {
it("maps every concrete theme to its base mode", () => {
for (const name of CONCRETE_THEMES) {
const expected = name.startsWith("dark") ? "dark" : "light";
expect(baseModeFor(name)).toBe(expected);
}
});
it("returns the expected mode for the documented concrete names", () => {
expect(baseModeFor("dark")).toBe("dark");
expect(baseModeFor("dark-protan-deuter")).toBe("dark");
expect(baseModeFor("dark-tritan")).toBe("dark");
expect(baseModeFor("light")).toBe("light");
expect(baseModeFor("light-protan-deuter")).toBe("light");
expect(baseModeFor("light-tritan")).toBe("light");
});
});
describe("colorblind role palettes", () => {
it("keeps protan-deuter error distinct from danger", () => {
expect(themes["light-protan-deuter"].roles.error).not.toEqual(
themes["light-protan-deuter"].roles.danger,
);
expect(themes["dark-protan-deuter"].roles.error).not.toEqual(
themes["dark-protan-deuter"].roles.danger,
);
});
it("keeps tritan danger on the base orange role", () => {
expect(themes["light-tritan"].roles.danger).toEqual(
themes.light.roles.danger,
);
expect(themes["dark-tritan"].roles.danger).toEqual(
themes.dark.roles.danger,
);
});
});
+181
View File
@@ -0,0 +1,181 @@
import fs from "node:fs";
import path from "node:path";
import { baseModeFor, CONCRETE_THEMES, type ConcreteThemeName } from ".";
const REQUIRED_VARIABLES = [
"--content-primary",
"--content-success",
"--content-destructive",
"--content-warning",
"--surface-primary",
"--border-default",
"--border-success",
"--border-destructive",
"--git-added",
"--git-deleted",
"--git-modified",
"--git-merged",
"--surface-git-added",
"--surface-git-deleted",
// Extended palette surface. These carry semantic color meaning in
// alerts, badges, chips, and syntax highlighting. A concrete theme
// must resolve every token after base mode and variant overrides are
// applied.
"--content-link",
"--surface-destructive",
"--surface-green",
"--surface-orange",
"--surface-sky",
"--surface-red",
"--surface-purple",
"--surface-magenta",
"--surface-git-merged",
"--border-warning",
"--border-sky",
"--border-green",
"--border-magenta",
"--border-purple",
"--highlight-purple",
"--highlight-green",
"--highlight-orange",
"--highlight-sky",
"--highlight-red",
"--highlight-magenta",
"--syntax-key",
"--syntax-string",
"--syntax-number",
"--syntax-boolean",
"--git-added-bright",
"--git-deleted-bright",
"--git-merged-bright",
];
const THEME_CLASSES = [
":root",
...CONCRETE_THEMES.map((themeName) => `.${themeName}`),
];
const COLORBLIND_THEME_CLASSES = [
".dark-protan-deuter",
".light-protan-deuter",
".dark-tritan",
".light-tritan",
];
const TRITAN_THEME_CLASSES = [".dark-tritan", ".light-tritan"];
function stripCssComments(css: string): string {
return css.replace(/\/\*[\s\S]*?\*\//g, "");
}
function extractBlock(css: string, selector: string): string | null {
const cssWithoutComments = stripCssComments(css);
for (const match of cssWithoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
const selectorList = match[1];
const block = match[2];
if (selectorList === undefined || block === undefined) {
continue;
}
const selectors = selectorList.split(",").map((value) => value.trim());
if (selectors.includes(selector)) {
return block;
}
}
return null;
}
function extractVariable(block: string, variable: string): string | null {
return extractVariables(block).get(variable) ?? null;
}
function extractVariables(block: string): Map<string, string> {
const variables = new Map<string, string>();
for (const match of block.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
const variable = match[1];
const value = match[2];
if (variable === undefined || value === undefined) {
continue;
}
variables.set(variable, value.trim());
}
return variables;
}
function extractEffectiveBlock(css: string, selector: string): string | null {
const block = extractBlock(css, selector);
if (block === null) {
return null;
}
if (!selector.startsWith(".") || !selector.includes("-")) {
return block;
}
const themeName = selector.slice(1) as ConcreteThemeName;
const baseBlock = extractBlock(css, `.${baseModeFor(themeName)}`);
if (baseBlock === null) {
return null;
}
return `${baseBlock}\n${block}`;
}
describe("theme CSS variables", () => {
const cssPath = path.resolve(__dirname, "../index.css");
const css = fs.readFileSync(cssPath, "utf8");
for (const selector of THEME_CLASSES) {
describe(selector, () => {
const block = extractBlock(css, selector);
const effectiveBlock = extractEffectiveBlock(css, selector);
it("has a rule block in index.css", () => {
expect(block).not.toBeNull();
});
if (effectiveBlock !== null) {
for (const variable of REQUIRED_VARIABLES) {
it(`resolves ${variable}`, () => {
expect(extractVariable(effectiveBlock, variable)).not.toBeNull();
});
}
}
});
}
for (const selector of COLORBLIND_THEME_CLASSES) {
describe(`${selector} semantic separation`, () => {
const block = extractEffectiveBlock(css, selector);
it("keeps warning distinct from destructive colors", () => {
expect(block).not.toBeNull();
expect(extractVariable(block ?? "", "--content-warning")).not.toBe(
extractVariable(block ?? "", "--content-destructive"),
);
expect(extractVariable(block ?? "", "--surface-orange")).not.toBe(
extractVariable(block ?? "", "--surface-red"),
);
});
it("keeps links distinct from success colors", () => {
expect(block).not.toBeNull();
expect(extractVariable(block ?? "", "--content-link")).not.toBe(
extractVariable(block ?? "", "--content-success"),
);
});
});
}
for (const selector of TRITAN_THEME_CLASSES) {
describe(`${selector} warning surface`, () => {
const block = extractEffectiveBlock(css, selector);
it("keeps warning surfaces on the fuchsia surface token", () => {
expect(block).not.toBeNull();
expect(extractVariable(block ?? "", "--surface-orange")).toBe(
extractVariable(block ?? "", "--surface-magenta"),
);
});
});
}
});
@@ -0,0 +1,4 @@
// Branding uses blue/violet/sky accents, all of which remain
// distinguishable under protanopia and deuteranopia. Reuse the base dark
// values rather than duplicating them.
export { default } from "../dark/branding";
@@ -0,0 +1,3 @@
// The experimental surface tokens are neutral (zinc) and do not carry any
// red/green semantic meaning, so the base dark values are already CVD-safe.
export { default } from "../dark/experimental";
+15
View File
@@ -0,0 +1,15 @@
import { forDarkThemes } from "../externalImages";
import branding from "./branding";
import experimental from "./experimental";
import monaco from "./monaco";
import muiTheme from "./mui";
import roles from "./roles";
export default {
...muiTheme,
externalImages: forDarkThemes,
experimental,
branding,
monaco,
roles,
};
@@ -0,0 +1,3 @@
// Monaco syntax highlighting is neutral hex-based and does not carry
// red/green semantic meaning; reuse the base dark theme's configuration.
export { default } from "../dark/monaco";
+12
View File
@@ -0,0 +1,12 @@
/**
* @deprecated MUI theme is deprecated. Migrate to Tailwind CSS theme system.
*
* MUI components are deprecated and the colorblind-friendly palette is
* expressed through `roles.ts` and the CSS variables in
* `site/src/index.css`, both of which drive the Tailwind-rendered UI that
* the diff panel and semantic roles use. We re-export the base dark MUI
* theme so `palette.mode === "dark"` stays correct for any remaining
* legacy MUI component that inspects it (for example, the Shiki theme
* selector in `DiffViewer.tsx`).
*/
export { default } from "../dark/mui";
+166
View File
@@ -0,0 +1,166 @@
import type { Roles } from "../roles";
import colors from "../tailwindColors";
// Protanopia and deuteranopia compress the red/green channel, so semantic
// "good/bad" pairs that rely on green vs red need a different axis. We
// shift destructive states onto a vermilion/orange hue (Tailwind orange
// scale, inspired by the Okabe-Ito CVD-safe scheme), positive/active
// states onto sky-blue, and warning onto fuchsia so it does not collide
// with destructive states on the orange axis. Preview stays on violet.
const roles: Roles = {
danger: {
background: colors.orange[950],
outline: colors.orange[500],
text: colors.orange[50],
fill: {
solid: colors.orange[500],
outline: colors.orange[400],
text: colors.white,
},
disabled: {
background: colors.orange[950],
outline: colors.orange[800],
text: colors.orange[200],
fill: {
solid: colors.orange[800],
outline: colors.orange[800],
text: colors.white,
},
},
hover: {
background: colors.orange[900],
outline: colors.orange[500],
text: colors.white,
fill: {
solid: colors.orange[500],
outline: colors.orange[500],
text: colors.white,
},
},
},
error: {
background: colors.red[950],
outline: colors.red[600],
text: colors.red[50],
fill: {
solid: colors.red[400],
outline: colors.red[400],
text: colors.white,
},
},
warning: {
background: colors.fuchsia[950],
outline: colors.fuchsia[300],
text: colors.fuchsia[50],
fill: {
solid: colors.fuchsia[500],
outline: colors.fuchsia[500],
text: colors.white,
},
},
notice: {
background: colors.blue[950],
outline: colors.blue[400],
text: colors.blue[50],
fill: {
solid: colors.blue[500],
outline: colors.blue[600],
text: colors.white,
},
},
info: {
background: colors.zinc[950],
outline: colors.zinc[400],
text: colors.zinc[50],
fill: {
solid: colors.zinc[500],
outline: colors.zinc[600],
text: colors.white,
},
},
// Success uses sky blue so it is distinguishable from `error` (red)
// under protanopia and deuteranopia. Green would blur into the red of
// `error` for most users with red/green CVD.
success: {
background: colors.sky[950],
outline: colors.sky[500],
text: colors.sky[50],
fill: {
solid: colors.sky[600],
outline: colors.sky[600],
text: colors.white,
},
disabled: {
background: colors.sky[950],
outline: colors.sky[800],
text: colors.sky[200],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[900],
outline: colors.sky[500],
text: colors.white,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
active: {
background: colors.sky[950],
outline: colors.sky[500],
text: colors.sky[50],
fill: {
solid: colors.sky[600],
outline: colors.sky[400],
text: colors.white,
},
disabled: {
background: colors.sky[950],
outline: colors.sky[800],
text: colors.sky[200],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[900],
outline: colors.sky[500],
text: colors.white,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
inactive: {
background: colors.zinc[950],
outline: colors.zinc[500],
text: colors.zinc[50],
fill: {
solid: colors.zinc[400],
outline: colors.zinc[400],
text: colors.white,
},
},
preview: {
background: colors.violet[950],
outline: colors.violet[500],
text: colors.violet[50],
fill: {
solid: colors.violet[400],
outline: colors.violet[400],
text: colors.white,
},
},
};
export default roles;
+1
View File
@@ -0,0 +1 @@
export { default } from "../dark/branding";
@@ -0,0 +1 @@
export { default } from "../dark/experimental";
+15
View File
@@ -0,0 +1,15 @@
import { forDarkThemes } from "../externalImages";
import branding from "./branding";
import experimental from "./experimental";
import monaco from "./monaco";
import muiTheme from "./mui";
import roles from "./roles";
export default {
...muiTheme,
externalImages: forDarkThemes,
experimental,
branding,
monaco,
roles,
};
+1
View File
@@ -0,0 +1 @@
export { default } from "../dark/monaco";
+9
View File
@@ -0,0 +1,9 @@
/**
* @deprecated MUI theme is deprecated. Migrate to Tailwind CSS theme system.
*
* The colorblind-friendly palette is expressed through `roles.ts` and the
* CSS variables in `site/src/index.css`. We re-export the base dark MUI
* theme so `palette.mode === "dark"` stays correct for any remaining
* legacy MUI component that inspects it.
*/
export { default } from "../dark/mui";
+164
View File
@@ -0,0 +1,164 @@
import type { Roles } from "../roles";
import colors from "../tailwindColors";
// Tritanopia reduces blue/yellow discrimination, so the standard amber
// warning can blur into the sky-blue active/notice accents. Under
// tritanopia, red vs green remains intact, so we keep `success` on green,
// `error` on red, and `danger` on the base orange. Only `warning` shifts
// to a magenta/pink that stays distinct from blue and red states.
const roles: Roles = {
danger: {
background: colors.orange[950],
outline: colors.orange[500],
text: colors.orange[50],
fill: {
solid: colors.orange[500],
outline: colors.orange[400],
text: colors.white,
},
disabled: {
background: colors.orange[950],
outline: colors.orange[800],
text: colors.orange[200],
fill: {
solid: colors.orange[800],
outline: colors.orange[800],
text: colors.white,
},
},
hover: {
background: colors.orange[900],
outline: colors.orange[500],
text: colors.white,
fill: {
solid: colors.orange[500],
outline: colors.orange[500],
text: colors.white,
},
},
},
error: {
background: colors.red[950],
outline: colors.red[600],
text: colors.red[50],
fill: {
solid: colors.red[400],
outline: colors.red[400],
text: colors.white,
},
},
// Warning shifts from amber to fuchsia because amber and sky blue blur
// together under tritanopia.
warning: {
background: colors.fuchsia[950],
outline: colors.fuchsia[300],
text: colors.fuchsia[50],
fill: {
solid: colors.fuchsia[500],
outline: colors.fuchsia[500],
text: colors.white,
},
},
notice: {
background: colors.blue[950],
outline: colors.blue[400],
text: colors.blue[50],
fill: {
solid: colors.blue[500],
outline: colors.blue[600],
text: colors.white,
},
},
info: {
background: colors.zinc[950],
outline: colors.zinc[400],
text: colors.zinc[50],
fill: {
solid: colors.zinc[500],
outline: colors.zinc[600],
text: colors.white,
},
},
success: {
background: colors.green[950],
outline: colors.green[500],
text: colors.green[50],
fill: {
solid: colors.green[600],
outline: colors.green[600],
text: colors.white,
},
disabled: {
background: colors.green[950],
outline: colors.green[800],
text: colors.green[200],
fill: {
solid: colors.green[800],
outline: colors.green[800],
text: colors.white,
},
},
hover: {
background: colors.green[900],
outline: colors.green[500],
text: colors.white,
fill: {
solid: colors.green[500],
outline: colors.green[500],
text: colors.white,
},
},
},
active: {
background: colors.sky[950],
outline: colors.sky[500],
text: colors.sky[50],
fill: {
solid: colors.sky[600],
outline: colors.sky[400],
text: colors.white,
},
disabled: {
background: colors.sky[950],
outline: colors.sky[800],
text: colors.sky[200],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[900],
outline: colors.sky[500],
text: colors.white,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
inactive: {
background: colors.zinc[950],
outline: colors.zinc[500],
text: colors.zinc[50],
fill: {
solid: colors.zinc[400],
outline: colors.zinc[400],
text: colors.white,
},
},
preview: {
background: colors.violet[950],
outline: colors.violet[500],
text: colors.violet[50],
fill: {
solid: colors.violet[400],
outline: colors.violet[400],
text: colors.white,
},
},
};
export default roles;
+47 -1
View File
@@ -3,9 +3,13 @@ import type { Theme as MuiTheme } from "@mui/material/styles";
import type * as monaco from "monaco-editor";
import type { Branding } from "./branding";
import dark from "./dark";
import darkProtanDeuter from "./darkProtanDeuter";
import darkTritan from "./darkTritan";
import type { NewTheme } from "./experimental";
import type { ExternalImageModeStyles } from "./externalImages";
import light from "./light";
import lightProtanDeuter from "./lightProtanDeuter";
import lightTritan from "./lightTritan";
import type { Roles } from "./roles";
export interface Theme extends Omit<MuiTheme, "palette"> {
@@ -30,9 +34,51 @@ export interface Theme extends Omit<MuiTheme, "palette"> {
export const DEFAULT_THEME = "dark";
export const CONCRETE_THEMES = [
"dark",
"light",
"dark-protan-deuter",
"light-protan-deuter",
"dark-tritan",
"light-tritan",
] as const;
export type ConcreteThemeName = (typeof CONCRETE_THEMES)[number];
const concreteThemeSet = new Set<string>(CONCRETE_THEMES);
export const isConcreteThemeName = (
value: unknown,
): value is ConcreteThemeName => {
return typeof value === "string" && concreteThemeSet.has(value);
};
export const resolveThemeName = (
preference: string | undefined,
osScheme: "dark" | "light",
): ConcreteThemeName => {
if (preference === "auto") {
return osScheme;
}
if (isConcreteThemeName(preference)) {
return preference;
}
return osScheme;
};
export const baseModeFor = (
concreteName: ConcreteThemeName,
): "dark" | "light" => {
return concreteName.startsWith("dark") ? "dark" : "light";
};
const theme = {
dark,
light,
} satisfies Record<string, Theme>;
"dark-protan-deuter": darkProtanDeuter,
"light-protan-deuter": lightProtanDeuter,
"dark-tritan": darkTritan,
"light-tritan": lightTritan,
} satisfies Record<ConcreteThemeName, Theme>;
export default theme;
@@ -0,0 +1 @@
export { default } from "../light/branding";
@@ -0,0 +1 @@
export { default } from "../light/experimental";
+15
View File
@@ -0,0 +1,15 @@
import { forLightThemes } from "../externalImages";
import branding from "./branding";
import experimental from "./experimental";
import monaco from "./monaco";
import muiTheme from "./mui";
import roles from "./roles";
export default {
...muiTheme,
externalImages: forLightThemes,
experimental,
branding,
monaco,
roles,
};
@@ -0,0 +1 @@
export { default } from "../light/monaco";
+10
View File
@@ -0,0 +1,10 @@
/**
* @deprecated MUI theme is deprecated. Migrate to Tailwind CSS theme system.
*
* The colorblind-friendly palette is expressed through `roles.ts` and the
* CSS variables in `site/src/index.css`. We re-export the base light MUI
* theme so `palette.mode === "light"` stays correct for any remaining
* legacy MUI component that inspects it, including the Shiki theme
* selector in `DiffViewer.tsx`.
*/
export { default } from "../light/mui";
+165
View File
@@ -0,0 +1,165 @@
import type { Roles } from "../roles";
import colors from "../tailwindColors";
// Protanopia and deuteranopia compress the red/green channel, so semantic
// "good/bad" pairs that rely on green vs red need a different axis. We
// shift destructive states onto a vermilion/orange hue (Tailwind orange
// scale, inspired by the Okabe-Ito CVD-safe scheme), positive/active
// states onto sky-blue, and warning onto fuchsia so it does not collide
// with destructive states on the orange axis. Preview stays on violet.
const roles: Roles = {
danger: {
background: colors.orange[50],
outline: colors.orange[400],
text: colors.orange[950],
fill: {
solid: colors.orange[600],
outline: colors.orange[600],
text: colors.white,
},
disabled: {
background: colors.orange[50],
outline: colors.orange[800],
text: colors.orange[800],
fill: {
solid: colors.orange[800],
outline: colors.orange[800],
text: colors.white,
},
},
hover: {
background: colors.orange[100],
outline: colors.orange[500],
text: colors.black,
fill: {
solid: colors.orange[500],
outline: colors.orange[500],
text: colors.white,
},
},
},
error: {
background: colors.red[100],
outline: colors.red[500],
text: colors.red[950],
fill: {
solid: colors.red[600],
outline: colors.red[600],
text: colors.white,
},
},
warning: {
background: colors.fuchsia[50],
outline: colors.fuchsia[300],
text: colors.fuchsia[950],
fill: {
solid: colors.fuchsia[500],
outline: colors.fuchsia[500],
text: colors.white,
},
},
notice: {
background: colors.blue[50],
outline: colors.blue[400],
text: colors.blue[950],
fill: {
solid: colors.blue[700],
outline: colors.blue[600],
text: colors.white,
},
},
info: {
background: colors.zinc[50],
outline: colors.zinc[400],
text: colors.zinc[950],
fill: {
solid: colors.zinc[700],
outline: colors.zinc[600],
text: colors.white,
},
},
// Success uses sky blue so it is distinguishable from `error` (red)
// under protanopia and deuteranopia.
success: {
background: colors.sky[100],
outline: colors.sky[500],
text: colors.sky[950],
fill: {
solid: colors.sky[600],
outline: colors.sky[600],
text: colors.white,
},
disabled: {
background: colors.sky[50],
outline: colors.sky[800],
text: colors.sky[800],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[200],
outline: colors.sky[500],
text: colors.black,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
active: {
background: colors.sky[100],
outline: colors.sky[500],
text: colors.sky[950],
fill: {
solid: colors.sky[600],
outline: colors.sky[600],
text: colors.white,
},
disabled: {
background: colors.sky[50],
outline: colors.sky[800],
text: colors.sky[200],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[200],
outline: colors.sky[400],
text: colors.black,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
inactive: {
background: colors.gray[100],
outline: colors.gray[400],
text: colors.gray[950],
fill: {
solid: colors.gray[600],
outline: colors.gray[600],
text: colors.white,
},
},
preview: {
background: colors.violet[50],
outline: colors.violet[500],
text: colors.violet[950],
fill: {
solid: colors.violet[600],
outline: colors.violet[600],
text: colors.white,
},
},
};
export default roles;
+1
View File
@@ -0,0 +1 @@
export { default } from "../light/branding";
@@ -0,0 +1 @@
export { default } from "../light/experimental";
+15
View File
@@ -0,0 +1,15 @@
import { forLightThemes } from "../externalImages";
import branding from "./branding";
import experimental from "./experimental";
import monaco from "./monaco";
import muiTheme from "./mui";
import roles from "./roles";
export default {
...muiTheme,
externalImages: forLightThemes,
experimental,
branding,
monaco,
roles,
};
+1
View File
@@ -0,0 +1 @@
export { default } from "../light/monaco";
+8
View File
@@ -0,0 +1,8 @@
/**
* @deprecated MUI theme is deprecated. Migrate to Tailwind CSS theme system.
*
* Re-exports the base light MUI theme so `palette.mode === "light"` stays
* correct for legacy MUI components. Colorblind palette overrides live in
* `roles.ts` and the CSS variables block in `site/src/index.css`.
*/
export { default } from "../light/mui";
+163
View File
@@ -0,0 +1,163 @@
import type { Roles } from "../roles";
import colors from "../tailwindColors";
// Tritanopia reduces blue/yellow discrimination. Red vs green remains
// intact, so we keep `success` on green, `error` on red, and `danger`
// on the base orange. Only `warning` shifts to a magenta/fuchsia that
// stays distinct from the blue accents and red destructive states.
const roles: Roles = {
danger: {
background: colors.orange[50],
outline: colors.orange[400],
text: colors.orange[950],
fill: {
solid: colors.orange[600],
outline: colors.orange[600],
text: colors.white,
},
disabled: {
background: colors.orange[50],
outline: colors.orange[800],
text: colors.orange[800],
fill: {
solid: colors.orange[800],
outline: colors.orange[800],
text: colors.white,
},
},
hover: {
background: colors.orange[100],
outline: colors.orange[500],
text: colors.black,
fill: {
solid: colors.orange[500],
outline: colors.orange[500],
text: colors.white,
},
},
},
error: {
background: colors.red[100],
outline: colors.red[500],
text: colors.red[950],
fill: {
solid: colors.red[600],
outline: colors.red[600],
text: colors.white,
},
},
// Warning shifts from amber to fuchsia because amber and sky blue blur
// together under tritanopia.
warning: {
background: colors.fuchsia[50],
outline: colors.fuchsia[300],
text: colors.fuchsia[950],
fill: {
solid: colors.fuchsia[500],
outline: colors.fuchsia[500],
text: colors.white,
},
},
notice: {
background: colors.blue[50],
outline: colors.blue[400],
text: colors.blue[950],
fill: {
solid: colors.blue[700],
outline: colors.blue[600],
text: colors.white,
},
},
info: {
background: colors.zinc[50],
outline: colors.zinc[400],
text: colors.zinc[950],
fill: {
solid: colors.zinc[700],
outline: colors.zinc[600],
text: colors.white,
},
},
success: {
background: colors.green[50],
outline: colors.green[500],
text: colors.green[950],
fill: {
solid: colors.green[600],
outline: colors.green[600],
text: colors.white,
},
disabled: {
background: colors.green[50],
outline: colors.green[800],
text: colors.green[800],
fill: {
solid: colors.green[800],
outline: colors.green[800],
text: colors.white,
},
},
hover: {
background: colors.green[100],
outline: colors.green[500],
text: colors.black,
fill: {
solid: colors.green[500],
outline: colors.green[500],
text: colors.white,
},
},
},
active: {
background: colors.sky[100],
outline: colors.sky[500],
text: colors.sky[950],
fill: {
solid: colors.sky[600],
outline: colors.sky[600],
text: colors.white,
},
disabled: {
background: colors.sky[50],
outline: colors.sky[800],
text: colors.sky[200],
fill: {
solid: colors.sky[800],
outline: colors.sky[800],
text: colors.white,
},
},
hover: {
background: colors.sky[200],
outline: colors.sky[400],
text: colors.black,
fill: {
solid: colors.sky[500],
outline: colors.sky[500],
text: colors.white,
},
},
},
inactive: {
background: colors.gray[100],
outline: colors.gray[400],
text: colors.gray[950],
fill: {
solid: colors.gray[600],
outline: colors.gray[600],
text: colors.white,
},
},
preview: {
background: colors.violet[50],
outline: colors.violet[500],
text: colors.violet[950],
fill: {
solid: colors.violet[600],
outline: colors.violet[600],
text: colors.white,
},
},
};
export default roles;