feat(site): add theme mode frontend foundation (#25181)

## Summary

- Add theme mode helpers for legacy migration, active theme resolution,
draft conversion, and mode switching.
- Add `usePreferredColorScheme` and refactor `ThemeProvider` to use the
shared theme mode resolver.
- Add reusable Appearance theme picker components plus isolated
Storybook coverage.

## Dependencies

- Stacked on #25180.

## Validation

- `pnpm -C site exec vitest run --project=unit
src/theme/themeMode.test.ts src/theme/usePreferredColorScheme.test.tsx`
- `pnpm -C site lint:types`
- `pnpm -C site lint:knip`
- Pre-commit hook passed on the branch commit.
This commit is contained in:
Jaayden Halko
2026-05-15 16:04:48 +07:00
committed by GitHub
parent c6ab379c32
commit e8cfff40b4
12 changed files with 1229 additions and 40 deletions
+11 -40
View File
@@ -16,57 +16,28 @@ import {
type PropsWithChildren,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { useQuery } from "react-query";
import { appearanceSettings } from "#/api/queries/users";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import themes, {
baseModeFor,
CONCRETE_THEMES,
DEFAULT_THEME,
resolveThemeName,
type Theme,
} from "#/theme";
import themes, { baseModeFor, CONCRETE_THEMES, type Theme } from "#/theme";
import {
migrateLegacyPreference,
resolveActiveThemeName,
} from "#/theme/themeMode";
import { usePreferredColorScheme } from "#/theme/usePreferredColorScheme";
export const ThemeProvider: FC<PropsWithChildren> = ({ children }) => {
const { metadata } = useEmbeddedMetadata();
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const themeQuery = useMemo(
() => window.matchMedia?.("(prefers-color-scheme: light)"),
[],
);
const [preferredColorScheme, setPreferredColorScheme] = useState<
"dark" | "light"
>(themeQuery?.matches ? "light" : "dark");
const preferredColorScheme = usePreferredColorScheme();
useEffect(() => {
if (!themeQuery) {
return;
}
const listener = (event: MediaQueryListEvent) => {
setPreferredColorScheme(event.matches ? "light" : "dark");
};
// `addEventListener` here is a recent API that isn't mocked in tests.
themeQuery.addEventListener?.("change", listener);
return () => {
themeQuery.removeEventListener?.("change", listener);
};
}, [themeQuery]);
// 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;
const concreteName = resolveThemeName(storedPreference, preferredColorScheme);
const settings =
appearanceSettingsQuery.data ?? metadata.userAppearance?.value ?? {};
const state = migrateLegacyPreference(settings);
const concreteName = resolveActiveThemeName(state, preferredColorScheme);
useEffect(() => {
const root = document.documentElement;
@@ -0,0 +1,86 @@
import type { FC } from "react";
import type { ConcreteThemeName } from "#/theme";
import { cn } from "#/utils/cn";
import { ThemePreview } from "./ThemePreview";
import { DARK_THEMES, LIGHT_THEMES, THEME_COPY } from "./themeCopy";
interface SingleModeSectionProps {
selected: ConcreteThemeName;
onSelect: (theme: ConcreteThemeName) => void;
}
const SINGLE_MODE_ORDER: ConcreteThemeName[] = [
...LIGHT_THEMES,
...DARK_THEMES,
];
export const SingleModeSection: FC<SingleModeSectionProps> = ({
selected,
onSelect,
}) => {
return (
<fieldset className="m-0 min-w-0 border-0 p-0">
<legend className="sr-only">Theme</legend>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{SINGLE_MODE_ORDER.map((theme) => (
<SingleTile
key={theme}
theme={theme}
selected={theme === selected}
onSelect={() => onSelect(theme)}
/>
))}
</div>
</fieldset>
);
};
interface SingleTileProps {
theme: ConcreteThemeName;
selected: boolean;
onSelect: () => void;
}
const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
const copy = THEME_COPY[theme];
return (
<label
className={cn(
"flex cursor-pointer flex-col gap-3 rounded-md border border-solid border-border p-4",
selected && "ring-2 ring-content-link",
"has-[input:focus-visible]:outline has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-offset-2 has-[input:focus-visible]:outline-content-link",
)}
>
<input
type="radio"
name="theme-single"
value={theme}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<div className="relative">
<ThemePreview theme={theme} size="lg" />
</div>
<div className="flex items-start gap-3">
<span
aria-hidden="true"
className={cn(
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border border-solid bg-surface-primary",
selected ? "border-border-secondary" : "border-border",
)}
>
{selected && (
<span className="size-2.5 rounded-full bg-surface-invert-primary" />
)}
</span>
<div className="flex flex-col gap-1">
<span className="font-medium text-content-primary">{copy.title}</span>
<span className="text-sm text-content-secondary">
{copy.description}
</span>
</div>
</div>
</label>
);
};
@@ -0,0 +1,109 @@
import { MoonIcon, SunIcon } from "lucide-react";
import { type FC, useState } from "react";
import { Badge } from "#/components/Badge/Badge";
import type { ConcreteThemeName } from "#/theme";
import { cn } from "#/utils/cn";
import { ThemePreview } from "./ThemePreview";
import { ThemeSwatch } from "./ThemeSwatch";
import { SYNC_MODE_THEMES, THEME_COPY } from "./themeCopy";
interface SyncModeSectionProps {
light: ConcreteThemeName;
dark: ConcreteThemeName;
activeScheme: "dark" | "light"; // The OS color scheme currently in effect
onSelect: (scheme: "light" | "dark", theme: ConcreteThemeName) => void;
}
export const SyncModeSection: FC<SyncModeSectionProps> = ({
light,
dark,
activeScheme,
onSelect,
}) => {
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<SyncCard
key="light"
scheme="light"
selected={light}
active={activeScheme === "light"}
onSelect={(theme) => onSelect("light", theme)}
/>
<SyncCard
key="dark"
scheme="dark"
selected={dark}
active={activeScheme === "dark"}
onSelect={(theme) => onSelect("dark", theme)}
/>
</div>
);
};
interface SyncCardProps {
scheme: "light" | "dark";
selected: ConcreteThemeName;
active: boolean;
onSelect: (theme: ConcreteThemeName) => void;
}
const SyncCard: FC<SyncCardProps> = ({
scheme,
selected,
active,
onSelect,
}) => {
const [previewTheme, setPreviewTheme] = useState<
ConcreteThemeName | undefined
>(undefined);
const Icon = scheme === "light" ? SunIcon : MoonIcon;
const title = scheme === "light" ? "Light theme" : "Dark theme";
const displayedTheme = previewTheme ?? selected;
const description =
scheme === "light"
? 'This theme will be active when your system is set to "light mode".'
: 'This theme will be active when your system is set to "dark mode".';
return (
<div
className={cn(
"flex flex-col gap-4 rounded-md border border-solid border-border p-4",
active && "ring-2 ring-content-link",
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="size-icon-sm text-content-secondary" />
<span className="font-medium text-content-primary">{title}</span>
</div>
{active && (
<Badge variant="info" size="sm">
Active
</Badge>
)}
</div>
<p className="m-0 text-sm text-content-secondary">{description}</p>
<ThemePreview
theme={displayedTheme}
size="lg"
label={THEME_COPY[displayedTheme].title}
/>
<fieldset className="m-0 min-w-0 border-0 p-0">
<legend className="sr-only">{`${title} options`}</legend>
<div className="flex flex-wrap items-center gap-3">
{SYNC_MODE_THEMES.map((theme) => (
<ThemeSwatch
key={theme}
name={`theme-sync-${scheme}`}
theme={theme}
selected={theme === selected}
onSelect={() => onSelect(theme)}
onPreview={() => setPreviewTheme(theme)}
onPreviewEnd={() => setPreviewTheme(undefined)}
/>
))}
</div>
</fieldset>
</div>
);
};
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useState } from "react";
import type { ConcreteThemeName } from "#/theme";
import type { ThemeModeDraft } from "#/theme/themeMode";
import { Section } from "../Section";
import { SingleModeSection } from "./SingleModeSection";
import { SyncModeSection } from "./SyncModeSection";
interface ThemeModeSectionsStoryProps {
activeScheme: "dark" | "light";
mode: "single" | "sync";
}
const meta: Meta<ThemeModeSectionsStoryProps> = {
title: "pages/UserSettingsPage/ThemeModeSections",
args: {
activeScheme: "light",
mode: "sync",
},
argTypes: {
activeScheme: {
control: "radio",
options: ["light", "dark"],
},
mode: {
control: "radio",
options: ["sync", "single"],
},
},
render: (args) => <ThemeModeSectionsStory {...args} />,
};
export default meta;
type Story = StoryObj<ThemeModeSectionsStoryProps>;
const initialDraft: ThemeModeDraft = {
mode: "sync",
single: "dark",
light: "light",
dark: "dark",
};
const ThemeModeSectionsStory: FC<ThemeModeSectionsStoryProps> = ({
activeScheme,
mode,
}) => {
const [draft, setDraft] = useState(initialDraft);
const selectSingle = (theme: ConcreteThemeName) => {
setDraft((current) => ({ ...current, single: theme }));
};
const selectSync = (scheme: "dark" | "light", theme: ConcreteThemeName) => {
setDraft((current) => ({ ...current, [scheme]: theme }));
};
return (
<div className="max-w-5xl p-8">
<Section title="Theme" layout="fluid">
{mode === "sync" ? (
<SyncModeSection
light={draft.light}
dark={draft.dark}
activeScheme={activeScheme}
onSelect={selectSync}
/>
) : (
<SingleModeSection selected={draft.single} onSelect={selectSingle} />
)}
</Section>
</div>
);
};
export const Sections: Story = {};
@@ -0,0 +1,136 @@
import type { CSSProperties, FC } from "react";
import { baseModeFor, type ConcreteThemeName } from "#/theme";
import { cn } from "#/utils/cn";
interface ThemePreviewProps {
theme: ConcreteThemeName;
size?: "sm" | "lg";
label?: string;
className?: string;
style?: CSSProperties;
}
/**
* Mini mockup of the Coder UI under a given theme.
* The header bar's two accent swatches visibly change between
* colorblind variants because they use `bg-git-added` / `bg-git-deleted`.
*/
export const ThemePreview: FC<ThemePreviewProps> = ({
theme,
size = "sm",
label,
className,
style,
}) => {
return (
<div className={cn(baseModeFor(theme), theme)}>
<div
className={cn(
"overflow-clip rounded-md border border-border border-solid bg-surface-primary text-content-primary select-none",
size === "sm" ? "w-56" : "w-full",
className,
)}
style={style}
>
<div className="bg-surface-primary text-content-primary">
<div
className={cn(
"bg-surface-primary flex items-center justify-between border-0 border-b border-border border-solid",
size === "sm" ? "px-2.5 py-1.5 mb-2" : "px-4 py-2.5 mb-3",
)}
>
<div
className={cn(
"flex items-center",
size === "sm" ? "gap-1.5" : "gap-2",
)}
>
<div
className={cn(
"bg-content-primary rounded",
size === "sm" ? "h-1.5 w-5" : "h-2 w-8",
)}
/>
<div
className={cn(
"bg-content-secondary rounded",
size === "sm" ? "h-1.5 w-5" : "h-2 w-8",
)}
/>
<div
className={cn(
"bg-content-secondary rounded",
size === "sm" ? "h-1.5 w-5" : "h-2 w-8",
)}
/>
</div>
<div
className={cn(
"flex items-center",
size === "sm" ? "gap-1.5" : "gap-2",
)}
>
<div
className={cn(
"bg-git-added rounded",
size === "sm" ? "h-1.5 w-3" : "h-2 w-4",
)}
/>
<div
className={cn(
"bg-git-deleted rounded",
size === "sm" ? "h-1.5 w-3" : "h-2 w-4",
)}
/>
</div>
</div>
<div
className={cn(
"mx-auto",
size === "sm" ? "w-32 pb-2" : "w-full max-w-md px-4 pb-6",
)}
>
<div
className={cn(
"bg-content-primary rounded mb-1.5",
size === "sm" ? "h-2 w-11" : "h-3 w-24",
)}
/>
<div className="flex gap-2">
<div
className={cn(
"rounded-md flex-1",
size === "sm" ? "h-6" : "h-10",
"bg-surface-git-added border border-solid border-border-default",
)}
>
<div
className={cn(
"bg-git-added h-full rounded-md",
size === "sm" ? "w-7" : "w-20",
)}
/>
</div>
<div
className={cn(
"rounded-md bg-surface-secondary",
size === "sm" ? "w-10 h-6" : "w-16 h-10",
)}
/>
</div>
</div>
</div>
{label && (
<div
className={cn(
"border-0 border-t border-border border-solid font-medium text-content-primary",
size === "sm" ? "px-2.5 py-1.5 text-xs" : "px-4 py-2 text-sm",
)}
>
{label}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,94 @@
import type { FC } from "react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
import { baseModeFor, type ConcreteThemeName } from "#/theme";
import { cn } from "#/utils/cn";
import { THEME_COPY } from "./themeCopy";
interface ThemeSwatchProps {
name: string;
theme: ConcreteThemeName;
selected: boolean;
onSelect: () => void;
onPreview?: () => void;
onPreviewEnd?: () => void;
}
export const ThemeSwatch: FC<ThemeSwatchProps> = ({
name,
theme,
selected,
onSelect,
onPreview,
onPreviewEnd,
}) => {
const copy = THEME_COPY[theme];
const isDefaultTheme = theme === "light" || theme === "dark";
const accentClass = theme.includes("protan-deuter")
? "bg-[#bf8700]"
: "bg-[#cf222e]";
return (
<Tooltip delayDuration={1000}>
<TooltipTrigger asChild>
<label
className={cn(
"inline-flex rounded-full size-8 p-0 border-2 border-solid cursor-pointer",
"transition-[outline] outline outline-2 outline-offset-2",
selected ? "outline-content-link" : "outline-transparent",
"border-border-default",
"has-[input:focus-visible]:outline-content-link has-[input:focus-visible]:outline-offset-2",
)}
onMouseEnter={onPreview}
onMouseLeave={onPreviewEnd}
onFocus={onPreview}
onBlur={onPreviewEnd}
>
<input
type="radio"
name={name}
value={theme}
checked={selected}
onChange={onSelect}
aria-label={copy.title}
className="sr-only"
/>
<span
className={cn(
baseModeFor(theme),
theme,
"block size-full rounded-full overflow-hidden",
"bg-surface-primary relative",
)}
>
{!isDefaultTheme && (
<>
<span
className="absolute inset-0 bg-[#0969da]"
style={{
clipPath: "polygon(0 100%, 50% 50%, 100% 100%)",
}}
/>
<span
className={cn("absolute inset-0", accentClass)}
style={{
clipPath: "polygon(100% 0, 100% 100%, 50% 50%)",
}}
/>
</>
)}
</span>
</label>
</TooltipTrigger>
<TooltipContent
side="bottom"
sideOffset={8}
className="text-content-primary"
>
{copy.title}
</TooltipContent>
</Tooltip>
);
};
@@ -0,0 +1,68 @@
import { CONCRETE_THEMES, type ConcreteThemeName } from "#/theme";
type ThemeCopy = {
title: string;
description: string;
};
export const THEME_COPY: Record<ConcreteThemeName, ThemeCopy> = {
light: {
title: "Light default",
description:
"Coder's standard light theme with full color contrast and brightness.",
},
"light-protan-deuter": {
title: "Light protanopia and deuteranopia",
description:
"For people who may find it difficult to distinguish between reds and greens.",
},
"light-tritan": {
title: "Light tritanopia",
description:
"For people who find it difficult to distinguish between blues and greens, as well as yellows and purples.",
},
dark: {
title: "Dark default",
description:
"Coder's standard dark theme with full color contrast and brightness on a dark background.",
},
"dark-protan-deuter": {
title: "Dark protanopia and deuteranopia",
description:
"For people who may find it difficult to distinguish between reds and greens, with a dark background.",
},
"dark-tritan": {
title: "Dark tritanopia",
description:
"For people who find it difficult to distinguish between blues and greens, as well as yellows and purples, with a dark background.",
},
};
export const LIGHT_THEMES: ConcreteThemeName[] = [
"light",
"light-protan-deuter",
"light-tritan",
];
export const DARK_THEMES: ConcreteThemeName[] = [
"dark",
"dark-protan-deuter",
"dark-tritan",
];
export const SYNC_MODE_THEMES: ConcreteThemeName[] = [
...LIGHT_THEMES,
...DARK_THEMES,
];
const syncModeThemes = SYNC_MODE_THEMES;
const themeCopyKeys = Object.keys(THEME_COPY);
if (
syncModeThemes.length !== CONCRETE_THEMES.length ||
themeCopyKeys.length !== CONCRETE_THEMES.length ||
!CONCRETE_THEMES.every((theme) => syncModeThemes.includes(theme))
) {
throw new Error(
"Theme copy registries are out of sync with CONCRETE_THEMES.",
);
}
@@ -0,0 +1,22 @@
import type { PropsWithChildren, ReactNode } from "react";
import { cn } from "#/utils/cn";
type SectionProps = PropsWithChildren<{
title: ReactNode;
layout?: "fluid" | "fixed";
className?: string;
}>;
export const Section: React.FC<SectionProps> = ({
title,
layout = "fixed",
className,
children,
}) => {
return (
<section className={cn("flex flex-col gap-6", className)}>
<h2 className="m-0 text-xl font-medium text-content-primary">{title}</h2>
<div className={cn(layout === "fixed" && "max-w-3xl")}>{children}</div>
</section>
);
};
+328
View File
@@ -0,0 +1,328 @@
import { CONCRETE_THEMES, DEFAULT_THEME } from ".";
import {
draftFromState,
draftToUpdate,
migrateLegacyPreference,
resolveActiveThemeName,
switchToSingle,
} from "./themeMode";
// A fake `UserAppearanceSettings` shape. We avoid importing the real
// type to keep these tests independent of codegen ordering.
const settings = (overrides: Record<string, string | undefined> = {}) => ({
theme_preference: overrides.theme_preference ?? "",
theme_mode: overrides.theme_mode ?? "",
theme_light: overrides.theme_light ?? "",
theme_dark: overrides.theme_dark ?? "",
terminal_font: overrides.terminal_font ?? "",
});
describe("migrateLegacyPreference", () => {
it("prefers the new fields when theme_mode=sync is set", () => {
expect(
migrateLegacyPreference(
settings({
theme_mode: "sync",
theme_light: "light-tritan",
theme_dark: "dark-tritan",
// Legacy field is ignored in sync mode.
theme_preference: "dark",
}),
),
).toEqual({
mode: "sync",
light: "light-tritan",
dark: "dark-tritan",
});
});
it("prefers the new fields when theme_mode=single is set", () => {
expect(
migrateLegacyPreference(
settings({
theme_mode: "single",
theme_preference: "dark-protan-deuter",
}),
),
).toEqual({
mode: "single",
theme: "dark-protan-deuter",
});
});
it("falls back to the OS-default light when theme_light is invalid in sync mode", () => {
expect(
migrateLegacyPreference(
settings({
theme_mode: "sync",
theme_light: "garbage",
theme_dark: "dark-tritan",
}),
),
).toEqual({
mode: "sync",
light: "light",
dark: "dark-tritan",
});
});
it("falls back to the OS-default dark when theme_dark is invalid in sync mode", () => {
expect(
migrateLegacyPreference(
settings({
theme_mode: "sync",
theme_light: "light-tritan",
theme_dark: "garbage",
}),
),
).toEqual({
mode: "sync",
light: "light-tritan",
dark: "dark",
});
});
it("migrates legacy auto to sync mode on read", () => {
expect(
migrateLegacyPreference(settings({ theme_preference: "auto" })),
).toEqual({ mode: "sync", light: "light", dark: "dark" });
});
it("preserves persisted slots while migrating legacy auto on read", () => {
expect(
migrateLegacyPreference(
settings({
theme_preference: "auto",
theme_light: "light-tritan",
theme_dark: "dark-protan-deuter",
}),
),
).toEqual({
mode: "sync",
light: "light-tritan",
dark: "dark-protan-deuter",
});
});
it("migrates legacy auto-protan-deuter to the protan-deuter pair", () => {
expect(
migrateLegacyPreference(
settings({ theme_preference: "auto-protan-deuter" }),
),
).toEqual({
mode: "sync",
light: "light-protan-deuter",
dark: "dark-protan-deuter",
});
});
it("migrates legacy auto-tritan to the tritan pair", () => {
expect(
migrateLegacyPreference(settings({ theme_preference: "auto-tritan" })),
).toEqual({
mode: "sync",
light: "light-tritan",
dark: "dark-tritan",
});
});
it("treats a concrete legacy preference as single mode", () => {
expect(
migrateLegacyPreference(settings({ theme_preference: "dark" })),
).toEqual({ mode: "single", theme: "dark" });
});
it("falls back to DEFAULT_THEME for empty or unknown legacy values", () => {
expect(migrateLegacyPreference(settings({}))).toEqual({
mode: "single",
theme: DEFAULT_THEME,
});
expect(
migrateLegacyPreference(settings({ theme_preference: "garbage" })),
).toEqual({ mode: "single", theme: DEFAULT_THEME });
});
it("falls back when theme_mode is unrecognized", () => {
// Defensive: an old client (or a hand-edited row) could set
// theme_mode to something we don't support. We should not crash.
expect(
migrateLegacyPreference(
settings({ theme_mode: "wizard", theme_preference: "light" }),
),
).toEqual({ mode: "single", theme: "light" });
});
});
describe("resolveActiveThemeName", () => {
it("returns the single theme regardless of OS scheme", () => {
expect(
resolveActiveThemeName({ mode: "single", theme: "dark-tritan" }, "light"),
).toBe("dark-tritan");
expect(
resolveActiveThemeName({ mode: "single", theme: "dark-tritan" }, "dark"),
).toBe("dark-tritan");
});
it("returns the matching sync slot for the current OS scheme", () => {
const state = {
mode: "sync" as const,
light: "light-protan-deuter" as const,
dark: "dark-tritan" as const,
};
expect(resolveActiveThemeName(state, "light")).toBe("light-protan-deuter");
expect(resolveActiveThemeName(state, "dark")).toBe("dark-tritan");
});
});
describe("switchToSingle", () => {
it("picks the dark slot when the OS scheme is dark", () => {
expect(
switchToSingle(
{ mode: "sync", light: "light-tritan", dark: "dark-tritan" },
"dark",
),
).toEqual({ mode: "single", theme: "dark-tritan" });
});
it("picks the light slot when the OS scheme is light", () => {
expect(
switchToSingle(
{ mode: "sync", light: "light-tritan", dark: "dark-tritan" },
"light",
),
).toEqual({ mode: "single", theme: "light-tritan" });
});
it("is a no-op when the input is already single", () => {
expect(
switchToSingle({ mode: "single", theme: "dark-protan-deuter" }, "light"),
).toEqual({ mode: "single", theme: "dark-protan-deuter" });
});
});
describe("draftToUpdate", () => {
// The form's internal draft always carries all four values (mode +
// single theme + light slot + dark slot). draftToUpdate is a flat
// mapping to the API request shape with no computation: switching
// mode on the form does not erase the "other" slots.
it("encodes a sync draft straight through", () => {
expect(
draftToUpdate(
{
mode: "sync",
single: "dark",
light: "light-protan-deuter",
dark: "dark-tritan",
},
"fira-code",
"light",
),
).toEqual({
theme_preference: "light-protan-deuter",
theme_mode: "sync",
theme_light: "light-protan-deuter",
theme_dark: "dark-tritan",
terminal_font: "fira-code",
});
});
it("mirrors the active dark slot for sync drafts on dark systems", () => {
expect(
draftToUpdate(
{
mode: "sync",
single: "dark",
light: "light-protan-deuter",
dark: "dark-tritan",
},
"fira-code",
"dark",
).theme_preference,
).toBe("dark-tritan");
});
it("encodes a single draft so the legacy field mirrors the single pick", () => {
expect(
draftToUpdate(
{
mode: "single",
single: "dark-protan-deuter",
light: "light-tritan",
dark: "dark-tritan",
},
"",
"light",
),
).toEqual({
theme_preference: "dark-protan-deuter",
theme_mode: "single",
theme_light: "light-tritan",
theme_dark: "dark-tritan",
terminal_font: "",
});
});
});
describe("draftFromState", () => {
it("preserves sync slots and seeds single from the dark slot", () => {
expect(
draftFromState({
mode: "sync",
light: "light-protan-deuter",
dark: "dark-tritan",
}),
).toEqual({
mode: "sync",
single: "dark-tritan",
light: "light-protan-deuter",
dark: "dark-tritan",
});
});
it("falls back to the FAMILY_PAIR of the single theme when no slots persist", () => {
expect(draftFromState({ mode: "single", theme: "dark-tritan" })).toEqual({
mode: "single",
single: "dark-tritan",
light: "light-tritan",
dark: "dark-tritan",
});
});
it("reuses persisted sync slots when the user is currently in single mode", () => {
expect(
draftFromState(
{ mode: "single", theme: "dark" },
{ light: "light-tritan", dark: "dark-protan-deuter" },
),
).toEqual({
mode: "single",
single: "dark",
light: "light-tritan",
dark: "dark-protan-deuter",
});
});
it("falls back to the family pair when persisted slots are garbage", () => {
expect(
draftFromState(
{ mode: "single", theme: "light-protan-deuter" },
{ light: "garbage", dark: "" },
),
).toEqual({
mode: "single",
single: "light-protan-deuter",
light: "light-protan-deuter",
dark: "dark-protan-deuter",
});
});
it("round-trips every concrete theme as a single-mode draft", () => {
for (const theme of CONCRETE_THEMES) {
const draft = draftFromState({ mode: "single", theme });
expect(draft.mode).toBe("single");
expect(draft.single).toBe(theme);
expect(draft.light.startsWith("light")).toBe(true);
expect(draft.dark.startsWith("dark")).toBe(true);
}
});
});
+188
View File
@@ -0,0 +1,188 @@
import type { TerminalFontName } from "#/api/typesGenerated";
import {
type ConcreteThemeName,
DEFAULT_THEME,
isConcreteThemeName,
legacyAutoToSync,
} from ".";
type ThemeMode = "sync" | "single";
type SyncState = {
mode: "sync";
light: ConcreteThemeName;
dark: ConcreteThemeName;
};
type SingleState = {
mode: "single";
theme: ConcreteThemeName;
};
type ThemeModeState = SyncState | SingleState;
export type ThemeModeDraft = {
mode: ThemeMode;
single: ConcreteThemeName;
light: ConcreteThemeName;
dark: ConcreteThemeName;
};
type AppearanceSettingsLike = {
theme_preference?: string;
theme_mode?: string;
theme_light?: string;
theme_dark?: string;
};
const coerceConcrete = (
value: string | undefined,
fallback: ConcreteThemeName,
): ConcreteThemeName => (isConcreteThemeName(value) ? value : fallback);
/**
* Maps every concrete theme to its opposite-scheme counterpart in the
* same palette family. Written out explicitly so the mapping is easy
* to review; a string-manipulation version would obscure edge cases
* like the bare `dark`/`light` pair versus the hyphenated variants.
*/
type ThemeFamilyPair = {
light: ConcreteThemeName;
dark: ConcreteThemeName;
};
const FAMILY_PAIR = {
light: { light: "light", dark: "dark" },
dark: { light: "light", dark: "dark" },
"light-protan-deuter": {
light: "light-protan-deuter",
dark: "dark-protan-deuter",
},
"dark-protan-deuter": {
light: "light-protan-deuter",
dark: "dark-protan-deuter",
},
"light-tritan": { light: "light-tritan", dark: "dark-tritan" },
"dark-tritan": { light: "light-tritan", dark: "dark-tritan" },
} satisfies Record<ConcreteThemeName, ThemeFamilyPair>;
/**
* Decodes the persisted appearance settings into the form's working
* state, applying the one-time migration of legacy `auto-*` values.
*/
export const migrateLegacyPreference = (
settings: AppearanceSettingsLike,
): ThemeModeState => {
const mode = settings.theme_mode;
if (mode === "sync") {
return {
mode: "sync",
light: coerceConcrete(settings.theme_light, "light"),
dark: coerceConcrete(settings.theme_dark, "dark"),
};
}
if (mode === "single") {
return {
mode: "single",
theme: coerceConcrete(settings.theme_preference, DEFAULT_THEME),
};
}
// No recognized theme_mode. Inspect the legacy theme_preference.
const legacySync = legacyAutoToSync(settings.theme_preference);
if (legacySync) {
return {
mode: "sync",
light: coerceConcrete(settings.theme_light, legacySync.light),
dark: coerceConcrete(settings.theme_dark, legacySync.dark),
};
}
return {
mode: "single",
theme: coerceConcrete(settings.theme_preference, DEFAULT_THEME),
};
};
export const resolveActiveThemeName = (
state: ThemeModeState,
osScheme: "dark" | "light",
): ConcreteThemeName => {
if (state.mode === "sync") {
return osScheme === "dark" ? state.dark : state.light;
}
return state.theme;
};
export const switchToSingle = (
state: ThemeModeState,
osScheme: "dark" | "light",
): SingleState => {
if (state.mode === "single") {
return state;
}
return {
mode: "single",
theme: osScheme === "dark" ? state.dark : state.light,
};
};
/**
* Flat request shape sent to the backend. Kept in sync with
* `codersdk.UpdateUserAppearanceSettingsRequest`; this helper lets the
* form code stay ignorant of the exact field ordering.
*/
type AppearanceUpdate = {
theme_preference: string;
theme_mode: ThemeMode;
theme_light: ConcreteThemeName;
theme_dark: ConcreteThemeName;
terminal_font: TerminalFontName;
};
export const draftToUpdate = (
draft: ThemeModeDraft,
terminalFont: TerminalFontName,
activeScheme: "dark" | "light",
): AppearanceUpdate => {
const themePreference =
draft.mode === "single"
? draft.single
: activeScheme === "dark"
? draft.dark
: draft.light;
return {
theme_preference: themePreference,
theme_mode: draft.mode,
theme_light: draft.light,
theme_dark: draft.dark,
terminal_font: terminalFont,
};
};
export const draftFromState = (
state: ThemeModeState,
persistedSlots?: { light?: string; dark?: string },
): ThemeModeDraft => {
if (state.mode === "sync") {
// Seed the "single" slot from the dark slot since historical
// default behavior preferred dark. If the user later switches
// modes via the dropdown, `switchToSingle` overrides this with
// the OS-matching slot anyway.
return {
mode: "sync",
single: state.dark,
light: state.light,
dark: state.dark,
};
}
const pair = FAMILY_PAIR[state.theme];
return {
mode: "single",
single: state.theme,
light: coerceConcrete(persistedSlots?.light, pair.light),
dark: coerceConcrete(persistedSlots?.dark, pair.dark),
};
};
@@ -0,0 +1,68 @@
import { act, renderHook } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { usePreferredColorScheme } from "./usePreferredColorScheme";
const stubMatchMedia = (matches: boolean) => {
const query = {
matches,
media: "(prefers-color-scheme: light)",
onchange: null,
addEventListener: vi.fn<MediaQueryList["addEventListener"]>(),
removeEventListener: vi.fn<MediaQueryList["removeEventListener"]>(),
addListener: vi.fn<MediaQueryList["addListener"]>(),
removeListener: vi.fn<MediaQueryList["removeListener"]>(),
dispatchEvent: vi.fn<MediaQueryList["dispatchEvent"]>(() => true),
} satisfies MediaQueryList;
vi.stubGlobal(
"matchMedia",
vi.fn(() => query),
);
return query;
};
afterEach(() => {
vi.unstubAllGlobals();
});
const ColorSchemeProbe = () => {
return <span>{usePreferredColorScheme()}</span>;
};
describe("usePreferredColorScheme", () => {
it("uses the stable default snapshot during server rendering", () => {
stubMatchMedia(true);
expect(renderToString(<ColorSchemeProbe />)).toContain(">dark</span>");
});
it("reads the browser color scheme after client rendering", () => {
stubMatchMedia(true);
const { result } = renderHook(() => usePreferredColorScheme());
expect(result.current).toBe("light");
});
it("updates when the browser color scheme changes", () => {
const query = stubMatchMedia(true);
const { result } = renderHook(() => usePreferredColorScheme());
expect(result.current).toBe("light");
const changeListener = query.addEventListener.mock.calls.find(
([eventName]) => eventName === "change",
)?.[1];
if (typeof changeListener !== "function") {
throw new Error("Expected change listener to be registered.");
}
query.matches = false;
act(() => {
changeListener(new Event("change"));
});
expect(result.current).toBe("dark");
});
});
+46
View File
@@ -0,0 +1,46 @@
import { useSyncExternalStore } from "react";
type PreferredColorScheme = "dark" | "light";
const defaultPreferredColorScheme: PreferredColorScheme = "dark";
const getColorSchemeQuery = () => {
return window.matchMedia?.("(prefers-color-scheme: light)");
};
const getPreferredColorScheme = (): PreferredColorScheme => {
const query = getColorSchemeQuery();
if (!query) {
// Match the server snapshot so hydration starts from one stable
// scheme before the browser media query becomes available.
return defaultPreferredColorScheme;
}
return query.matches ? "light" : "dark";
};
const getServerPreferredColorScheme = (): PreferredColorScheme => {
// React calls the server snapshot during client hydration, so this must
// stay independent of browser state such as matchMedia.
return defaultPreferredColorScheme;
};
const subscribePreferredColorScheme = (onStoreChange: () => void) => {
const query = getColorSchemeQuery();
if (!query) {
return () => {
// No listener was registered when matchMedia is unavailable.
};
}
query.addEventListener?.("change", onStoreChange);
return () => {
query.removeEventListener?.("change", onStoreChange);
};
};
export const usePreferredColorScheme = (): PreferredColorScheme => {
return useSyncExternalStore(
subscribePreferredColorScheme,
getPreferredColorScheme,
getServerPreferredColorScheme,
);
};