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;