diff --git a/site/src/contexts/ThemeProvider.tsx b/site/src/contexts/ThemeProvider.tsx index f9fcf25714..0863606cfe 100644 --- a/site/src/contexts/ThemeProvider.tsx +++ b/site/src/contexts/ThemeProvider.tsx @@ -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 = ({ 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; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx b/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx new file mode 100644 index 0000000000..8595584b92 --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/SingleModeSection.tsx @@ -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 = ({ + selected, + onSelect, +}) => { + return ( +
+ Theme +
+ {SINGLE_MODE_ORDER.map((theme) => ( + onSelect(theme)} + /> + ))} +
+
+ ); +}; + +interface SingleTileProps { + theme: ConcreteThemeName; + selected: boolean; + onSelect: () => void; +} + +const SingleTile: FC = ({ theme, selected, onSelect }) => { + const copy = THEME_COPY[theme]; + return ( + + ); +}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/SyncModeSection.tsx b/site/src/pages/UserSettingsPage/AppearancePage/SyncModeSection.tsx new file mode 100644 index 0000000000..586d25be4e --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/SyncModeSection.tsx @@ -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 = ({ + light, + dark, + activeScheme, + onSelect, +}) => { + return ( +
+ onSelect("light", theme)} + /> + onSelect("dark", theme)} + /> +
+ ); +}; + +interface SyncCardProps { + scheme: "light" | "dark"; + selected: ConcreteThemeName; + active: boolean; + onSelect: (theme: ConcreteThemeName) => void; +} + +const SyncCard: FC = ({ + 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 ( +
+
+
+ + {title} +
+ {active && ( + + Active + + )} +
+

{description}

+ +
+ {`${title} options`} +
+ {SYNC_MODE_THEMES.map((theme) => ( + onSelect(theme)} + onPreview={() => setPreviewTheme(theme)} + onPreviewEnd={() => setPreviewTheme(undefined)} + /> + ))} +
+
+
+ ); +}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/ThemeModeSections.stories.tsx b/site/src/pages/UserSettingsPage/AppearancePage/ThemeModeSections.stories.tsx new file mode 100644 index 0000000000..3785e1defc --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/ThemeModeSections.stories.tsx @@ -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 = { + title: "pages/UserSettingsPage/ThemeModeSections", + args: { + activeScheme: "light", + mode: "sync", + }, + argTypes: { + activeScheme: { + control: "radio", + options: ["light", "dark"], + }, + mode: { + control: "radio", + options: ["sync", "single"], + }, + }, + render: (args) => , +}; + +export default meta; +type Story = StoryObj; + +const initialDraft: ThemeModeDraft = { + mode: "sync", + single: "dark", + light: "light", + dark: "dark", +}; + +const ThemeModeSectionsStory: FC = ({ + 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 ( +
+
+ {mode === "sync" ? ( + + ) : ( + + )} +
+
+ ); +}; + +export const Sections: Story = {}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/ThemePreview.tsx b/site/src/pages/UserSettingsPage/AppearancePage/ThemePreview.tsx new file mode 100644 index 0000000000..0bdcca7c18 --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/ThemePreview.tsx @@ -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 = ({ + theme, + size = "sm", + label, + className, + style, +}) => { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {label && ( +
+ {label} +
+ )} +
+
+ ); +}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/ThemeSwatch.tsx b/site/src/pages/UserSettingsPage/AppearancePage/ThemeSwatch.tsx new file mode 100644 index 0000000000..0ddd3c33e1 --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/ThemeSwatch.tsx @@ -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 = ({ + 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 ( + + + + + + {copy.title} + + + ); +}; diff --git a/site/src/pages/UserSettingsPage/AppearancePage/themeCopy.ts b/site/src/pages/UserSettingsPage/AppearancePage/themeCopy.ts new file mode 100644 index 0000000000..428a6f131b --- /dev/null +++ b/site/src/pages/UserSettingsPage/AppearancePage/themeCopy.ts @@ -0,0 +1,68 @@ +import { CONCRETE_THEMES, type ConcreteThemeName } from "#/theme"; + +type ThemeCopy = { + title: string; + description: string; +}; + +export const THEME_COPY: Record = { + 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.", + ); +} diff --git a/site/src/pages/UserSettingsPage/Section.tsx b/site/src/pages/UserSettingsPage/Section.tsx new file mode 100644 index 0000000000..2d6cecfb28 --- /dev/null +++ b/site/src/pages/UserSettingsPage/Section.tsx @@ -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 = ({ + title, + layout = "fixed", + className, + children, +}) => { + return ( +
+

{title}

+
{children}
+
+ ); +}; diff --git a/site/src/theme/themeMode.test.ts b/site/src/theme/themeMode.test.ts new file mode 100644 index 0000000000..cb02a0941a --- /dev/null +++ b/site/src/theme/themeMode.test.ts @@ -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 = {}) => ({ + 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); + } + }); +}); diff --git a/site/src/theme/themeMode.ts b/site/src/theme/themeMode.ts new file mode 100644 index 0000000000..75cc8b2f9d --- /dev/null +++ b/site/src/theme/themeMode.ts @@ -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; + +/** + * 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), + }; +}; diff --git a/site/src/theme/usePreferredColorScheme.test.tsx b/site/src/theme/usePreferredColorScheme.test.tsx new file mode 100644 index 0000000000..00090426d2 --- /dev/null +++ b/site/src/theme/usePreferredColorScheme.test.tsx @@ -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(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(() => true), + } satisfies MediaQueryList; + + vi.stubGlobal( + "matchMedia", + vi.fn(() => query), + ); + + return query; +}; +afterEach(() => { + vi.unstubAllGlobals(); +}); + +const ColorSchemeProbe = () => { + return {usePreferredColorScheme()}; +}; + +describe("usePreferredColorScheme", () => { + it("uses the stable default snapshot during server rendering", () => { + stubMatchMedia(true); + + expect(renderToString()).toContain(">dark"); + }); + + 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"); + }); +}); diff --git a/site/src/theme/usePreferredColorScheme.ts b/site/src/theme/usePreferredColorScheme.ts new file mode 100644 index 0000000000..b58757dddb --- /dev/null +++ b/site/src/theme/usePreferredColorScheme.ts @@ -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, + ); +};