mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 />
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../dark/branding";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../dark/experimental";
|
||||
@@ -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 @@
|
||||
export { default } from "../dark/monaco";
|
||||
@@ -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";
|
||||
@@ -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
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../light/branding";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../light/experimental";
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user