mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user