From 6149fc36196ad6eb83c0a6a33c089f0f7d142b2c Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 4 May 2026 20:02:58 +0700 Subject: [PATCH] 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.
Implementation plan and decision log Full plan (investigation, file layout, TDD phases, open risks) is attached to the chat that produced this PR. Key decisions: - **Sitewide, not agent-scoped.** The diff panel already consumes the sitewide theme via CSS variables. Keeping the change at the user appearance layer also fixes red/green accents in alerts, badges, and build states in one change, and matches how comparable products (e.g. GitHub) ship this feature. - **No backend change.** `codersdk/users.go` already accepts any `theme_preference` string, and `ThemeProvider` now has a shared resolver (`resolveThemeName`) that maps any persisted value to a concrete theme, tolerating unknowns and the legacy `"auto"` value. - **Palette provenance.** The protan/deuter palette 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.
--- site/.storybook/preview.tsx | 19 +- site/e2e/tests/users/userSettings.spec.ts | 25 ++- site/src/contexts/ThemeProvider.tsx | 37 ++-- site/src/index.css | 184 +++++++++++------- site/src/pages/AgentsPage/AgentEmbedPage.tsx | 23 ++- site/src/theme/colorblind.test.ts | 123 ++++++++++++ site/src/theme/cssVariables.test.ts | 181 +++++++++++++++++ site/src/theme/darkProtanDeuter/branding.ts | 4 + .../theme/darkProtanDeuter/experimental.ts | 3 + site/src/theme/darkProtanDeuter/index.ts | 15 ++ site/src/theme/darkProtanDeuter/monaco.ts | 3 + site/src/theme/darkProtanDeuter/mui.ts | 12 ++ site/src/theme/darkProtanDeuter/roles.ts | 166 ++++++++++++++++ site/src/theme/darkTritan/branding.ts | 1 + site/src/theme/darkTritan/experimental.ts | 1 + site/src/theme/darkTritan/index.ts | 15 ++ site/src/theme/darkTritan/monaco.ts | 1 + site/src/theme/darkTritan/mui.ts | 9 + site/src/theme/darkTritan/roles.ts | 164 ++++++++++++++++ site/src/theme/index.ts | 48 ++++- site/src/theme/lightProtanDeuter/branding.ts | 1 + .../theme/lightProtanDeuter/experimental.ts | 1 + site/src/theme/lightProtanDeuter/index.ts | 15 ++ site/src/theme/lightProtanDeuter/monaco.ts | 1 + site/src/theme/lightProtanDeuter/mui.ts | 10 + site/src/theme/lightProtanDeuter/roles.ts | 165 ++++++++++++++++ site/src/theme/lightTritan/branding.ts | 1 + site/src/theme/lightTritan/experimental.ts | 1 + site/src/theme/lightTritan/index.ts | 15 ++ site/src/theme/lightTritan/monaco.ts | 1 + site/src/theme/lightTritan/mui.ts | 8 + site/src/theme/lightTritan/roles.ts | 163 ++++++++++++++++ 32 files changed, 1302 insertions(+), 114 deletions(-) create mode 100644 site/src/theme/colorblind.test.ts create mode 100644 site/src/theme/cssVariables.test.ts create mode 100644 site/src/theme/darkProtanDeuter/branding.ts create mode 100644 site/src/theme/darkProtanDeuter/experimental.ts create mode 100644 site/src/theme/darkProtanDeuter/index.ts create mode 100644 site/src/theme/darkProtanDeuter/monaco.ts create mode 100644 site/src/theme/darkProtanDeuter/mui.ts create mode 100644 site/src/theme/darkProtanDeuter/roles.ts create mode 100644 site/src/theme/darkTritan/branding.ts create mode 100644 site/src/theme/darkTritan/experimental.ts create mode 100644 site/src/theme/darkTritan/index.ts create mode 100644 site/src/theme/darkTritan/monaco.ts create mode 100644 site/src/theme/darkTritan/mui.ts create mode 100644 site/src/theme/darkTritan/roles.ts create mode 100644 site/src/theme/lightProtanDeuter/branding.ts create mode 100644 site/src/theme/lightProtanDeuter/experimental.ts create mode 100644 site/src/theme/lightProtanDeuter/index.ts create mode 100644 site/src/theme/lightProtanDeuter/monaco.ts create mode 100644 site/src/theme/lightProtanDeuter/mui.ts create mode 100644 site/src/theme/lightProtanDeuter/roles.ts create mode 100644 site/src/theme/lightTritan/branding.ts create mode 100644 site/src/theme/lightTritan/experimental.ts create mode 100644 site/src/theme/lightTritan/index.ts create mode 100644 site/src/theme/lightTritan/monaco.ts create mode 100644 site/src/theme/lightTritan/mui.ts create mode 100644 site/src/theme/lightTritan/roles.ts diff --git a/site/.storybook/preview.tsx b/site/.storybook/preview.tsx index 037356db6d..a864321530 100644 --- a/site/.storybook/preview.tsx +++ b/site/.storybook/preview.tsx @@ -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 ( - - + + diff --git a/site/e2e/tests/users/userSettings.spec.ts b/site/e2e/tests/users/userSettings.spec.ts index f1edb7f95a..39dd398765 100644 --- a/site/e2e/tests/users/userSettings.spec.ts +++ b/site/e2e/tests/users/userSettings.spec.ts @@ -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)); }); diff --git a/site/src/contexts/ThemeProvider.tsx b/site/src/contexts/ThemeProvider.tsx index 6ba13b78d9..f9fcf25714 100644 --- a/site/src/contexts/ThemeProvider.tsx +++ b/site/src/contexts/ThemeProvider.tsx @@ -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 = ({ children }) => { const { metadata } = useEmbeddedMetadata(); const appearanceSettingsQuery = useQuery( @@ -56,15 +59,14 @@ export const ThemeProvider: FC = ({ 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 = ({ 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 ( diff --git a/site/src/index.css b/site/src/index.css index 2d10dfa13e..098396238f 100644 --- a/site/src/index.css +++ b/site/src/index.css @@ -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; diff --git a/site/src/pages/AgentsPage/AgentEmbedPage.tsx b/site/src/pages/AgentsPage/AgentEmbedPage.tsx index 729d03cb4b..c256f0f3d8 100644 --- a/site/src/pages/AgentsPage/AgentEmbedPage.tsx +++ b/site/src/pages/AgentsPage/AgentEmbedPage.tsx @@ -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=) 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]); diff --git a/site/src/theme/colorblind.test.ts b/site/src/theme/colorblind.test.ts new file mode 100644 index 0000000000..99a03d4d05 --- /dev/null +++ b/site/src/theme/colorblind.test.ts @@ -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, + ); + }); +}); diff --git a/site/src/theme/cssVariables.test.ts b/site/src/theme/cssVariables.test.ts new file mode 100644 index 0000000000..0f8b2ee4e5 --- /dev/null +++ b/site/src/theme/cssVariables.test.ts @@ -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 { + const variables = new Map(); + 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"), + ); + }); + }); + } +}); diff --git a/site/src/theme/darkProtanDeuter/branding.ts b/site/src/theme/darkProtanDeuter/branding.ts new file mode 100644 index 0000000000..fb36fdd0f0 --- /dev/null +++ b/site/src/theme/darkProtanDeuter/branding.ts @@ -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"; diff --git a/site/src/theme/darkProtanDeuter/experimental.ts b/site/src/theme/darkProtanDeuter/experimental.ts new file mode 100644 index 0000000000..63c8eac53b --- /dev/null +++ b/site/src/theme/darkProtanDeuter/experimental.ts @@ -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"; diff --git a/site/src/theme/darkProtanDeuter/index.ts b/site/src/theme/darkProtanDeuter/index.ts new file mode 100644 index 0000000000..29cec24923 --- /dev/null +++ b/site/src/theme/darkProtanDeuter/index.ts @@ -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, +}; diff --git a/site/src/theme/darkProtanDeuter/monaco.ts b/site/src/theme/darkProtanDeuter/monaco.ts new file mode 100644 index 0000000000..5940a13529 --- /dev/null +++ b/site/src/theme/darkProtanDeuter/monaco.ts @@ -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"; diff --git a/site/src/theme/darkProtanDeuter/mui.ts b/site/src/theme/darkProtanDeuter/mui.ts new file mode 100644 index 0000000000..b59dca2f3e --- /dev/null +++ b/site/src/theme/darkProtanDeuter/mui.ts @@ -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"; diff --git a/site/src/theme/darkProtanDeuter/roles.ts b/site/src/theme/darkProtanDeuter/roles.ts new file mode 100644 index 0000000000..43abcfe93f --- /dev/null +++ b/site/src/theme/darkProtanDeuter/roles.ts @@ -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; diff --git a/site/src/theme/darkTritan/branding.ts b/site/src/theme/darkTritan/branding.ts new file mode 100644 index 0000000000..6775cfa7fa --- /dev/null +++ b/site/src/theme/darkTritan/branding.ts @@ -0,0 +1 @@ +export { default } from "../dark/branding"; diff --git a/site/src/theme/darkTritan/experimental.ts b/site/src/theme/darkTritan/experimental.ts new file mode 100644 index 0000000000..bd04950aa5 --- /dev/null +++ b/site/src/theme/darkTritan/experimental.ts @@ -0,0 +1 @@ +export { default } from "../dark/experimental"; diff --git a/site/src/theme/darkTritan/index.ts b/site/src/theme/darkTritan/index.ts new file mode 100644 index 0000000000..29cec24923 --- /dev/null +++ b/site/src/theme/darkTritan/index.ts @@ -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, +}; diff --git a/site/src/theme/darkTritan/monaco.ts b/site/src/theme/darkTritan/monaco.ts new file mode 100644 index 0000000000..dc45f4418d --- /dev/null +++ b/site/src/theme/darkTritan/monaco.ts @@ -0,0 +1 @@ +export { default } from "../dark/monaco"; diff --git a/site/src/theme/darkTritan/mui.ts b/site/src/theme/darkTritan/mui.ts new file mode 100644 index 0000000000..80e0ddc632 --- /dev/null +++ b/site/src/theme/darkTritan/mui.ts @@ -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"; diff --git a/site/src/theme/darkTritan/roles.ts b/site/src/theme/darkTritan/roles.ts new file mode 100644 index 0000000000..4d3492ce5d --- /dev/null +++ b/site/src/theme/darkTritan/roles.ts @@ -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; diff --git a/site/src/theme/index.ts b/site/src/theme/index.ts index 50c16b3257..80d1a4df02 100644 --- a/site/src/theme/index.ts +++ b/site/src/theme/index.ts @@ -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 { @@ -30,9 +34,51 @@ export interface Theme extends Omit { 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(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; + "dark-protan-deuter": darkProtanDeuter, + "light-protan-deuter": lightProtanDeuter, + "dark-tritan": darkTritan, + "light-tritan": lightTritan, +} satisfies Record; export default theme; diff --git a/site/src/theme/lightProtanDeuter/branding.ts b/site/src/theme/lightProtanDeuter/branding.ts new file mode 100644 index 0000000000..958e526d05 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/branding.ts @@ -0,0 +1 @@ +export { default } from "../light/branding"; diff --git a/site/src/theme/lightProtanDeuter/experimental.ts b/site/src/theme/lightProtanDeuter/experimental.ts new file mode 100644 index 0000000000..d931dff250 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/experimental.ts @@ -0,0 +1 @@ +export { default } from "../light/experimental"; diff --git a/site/src/theme/lightProtanDeuter/index.ts b/site/src/theme/lightProtanDeuter/index.ts new file mode 100644 index 0000000000..fd50cbd9f9 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/index.ts @@ -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, +}; diff --git a/site/src/theme/lightProtanDeuter/monaco.ts b/site/src/theme/lightProtanDeuter/monaco.ts new file mode 100644 index 0000000000..6b3c6351c1 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/monaco.ts @@ -0,0 +1 @@ +export { default } from "../light/monaco"; diff --git a/site/src/theme/lightProtanDeuter/mui.ts b/site/src/theme/lightProtanDeuter/mui.ts new file mode 100644 index 0000000000..7f75c070c1 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/mui.ts @@ -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"; diff --git a/site/src/theme/lightProtanDeuter/roles.ts b/site/src/theme/lightProtanDeuter/roles.ts new file mode 100644 index 0000000000..1fdefd8345 --- /dev/null +++ b/site/src/theme/lightProtanDeuter/roles.ts @@ -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; diff --git a/site/src/theme/lightTritan/branding.ts b/site/src/theme/lightTritan/branding.ts new file mode 100644 index 0000000000..958e526d05 --- /dev/null +++ b/site/src/theme/lightTritan/branding.ts @@ -0,0 +1 @@ +export { default } from "../light/branding"; diff --git a/site/src/theme/lightTritan/experimental.ts b/site/src/theme/lightTritan/experimental.ts new file mode 100644 index 0000000000..d931dff250 --- /dev/null +++ b/site/src/theme/lightTritan/experimental.ts @@ -0,0 +1 @@ +export { default } from "../light/experimental"; diff --git a/site/src/theme/lightTritan/index.ts b/site/src/theme/lightTritan/index.ts new file mode 100644 index 0000000000..fd50cbd9f9 --- /dev/null +++ b/site/src/theme/lightTritan/index.ts @@ -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, +}; diff --git a/site/src/theme/lightTritan/monaco.ts b/site/src/theme/lightTritan/monaco.ts new file mode 100644 index 0000000000..6b3c6351c1 --- /dev/null +++ b/site/src/theme/lightTritan/monaco.ts @@ -0,0 +1 @@ +export { default } from "../light/monaco"; diff --git a/site/src/theme/lightTritan/mui.ts b/site/src/theme/lightTritan/mui.ts new file mode 100644 index 0000000000..718b6aa2b7 --- /dev/null +++ b/site/src/theme/lightTritan/mui.ts @@ -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"; diff --git a/site/src/theme/lightTritan/roles.ts b/site/src/theme/lightTritan/roles.ts new file mode 100644 index 0000000000..49efb72950 --- /dev/null +++ b/site/src/theme/lightTritan/roles.ts @@ -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;