feat: add theme mode dropdown (#25183)

## Summary

- Wire the Appearance settings page to the new theme mode dropdown and
sync or single theme selectors.
- Update Appearance page tests and stories for theme mode behavior.
- Update the user settings e2e test to exercise single theme selection.

## Dependencies

- Depends on #25076, #25180, #25181, and #25182.
- This PR targets helper branch `pr25077/05-theme-mode-dropdown-base`,
which contains dependency commits only, so this PR diff stays focused on
final dropdown wiring. Rebase and retarget after the dependency PRs
merge.

## Validation

- `pnpm -C site exec vitest run --project=unit
src/pages/UserSettingsPage/AppearancePage/AppearancePage.test.tsx
src/theme/themeMode.test.ts src/api/queries/users.test.ts`
- `pnpm -C site lint:types`
- `pnpm -C site storybook:ci`
- `pnpm -C site build`
- `pnpm -C site playwright:test -- e2e/tests/users/userSettings.spec.ts`
- Pre-commit hook passed on the branch commit.
This commit is contained in:
Jaayden Halko
2026-05-15 22:15:06 +07:00
committed by GitHub
parent 6d7fb07f4c
commit 2c18e07e39
7 changed files with 826 additions and 375 deletions
+21 -13
View File
@@ -12,15 +12,19 @@ const rootClassNames = async (page: Page) => {
return page.locator("html").evaluate((it) => Array.from(it.classList));
};
// Assert the light theme without rejecting unrelated root classes.
const expectLightThemeClasses = (classes: string[]) => {
const className = "light";
expect(classes).toContain(className);
for (const themeClassName of CONCRETE_THEMES.filter(
(it) => it !== className,
)) {
expect(classes).not.toContain(themeClassName);
}
const expectLightThemeClasses = async (page: Page) => {
await expect(async () => {
const classes = await rootClassNames(page);
const className = "light";
// Assert the light theme without rejecting unrelated root classes.
expect(classes).toContain(className);
for (const themeClassName of CONCRETE_THEMES.filter(
(it) => it !== className,
)) {
expect(classes).not.toContain(themeClassName);
}
}).toPass({ timeout: 10_000 });
};
test("adjust user theme preference", async ({ page }) => {
@@ -28,14 +32,18 @@ test("adjust user theme preference", async ({ page }) => {
await page.goto("/settings/appearance", { waitUntil: "domcontentloaded" });
await page.getByText("Light", { exact: true }).click();
await expect(page.getByLabel("Light")).toBeChecked();
await page.getByRole("combobox", { name: /theme mode/i }).click();
await page.getByRole("option", { name: /single theme/i }).click();
expectLightThemeClasses(await rootClassNames(page));
const singleThemeGroup = page.getByRole("group", { name: "Theme" });
await expect(singleThemeGroup).toBeVisible();
await singleThemeGroup.getByText("Light default", { exact: true }).click();
await expectLightThemeClasses(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
// Make sure the page is still using the light theme after reloading and
// navigating away from the settings page.
expectLightThemeClasses(await rootClassNames(page));
await expectLightThemeClasses(page);
});
@@ -1,25 +1,510 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, useState } from "react";
import { action } from "storybook/actions";
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
import type {
UpdateUserAppearanceSettingsRequest,
UserAppearanceSettings,
} from "#/api/typesGenerated";
import { CONCRETE_THEMES } from "#/theme";
import { AppearanceForm } from "./AppearanceForm";
const onUpdateTheme = action("update");
const baseSettings: UserAppearanceSettings = {
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "",
};
const resolvedSubmit = () =>
fn((update: UpdateUserAppearanceSettingsRequest) => {
onUpdateTheme(update);
return Promise.resolve({ ...baseSettings, ...update });
});
interface ResyncHarnessProps {
initialValues: UserAppearanceSettings;
onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void;
}
const ResyncHarness: FC<ResyncHarnessProps> = ({ initialValues, onSubmit }) => {
const [settings, setSettings] = useState(initialValues);
return (
<div className="flex flex-col gap-6">
<button
type="button"
className="w-fit rounded-md border border-solid border-border px-3 py-2"
onClick={() =>
setSettings({ ...baseSettings, theme_preference: "light" })
}
>
Load light default
</button>
<AppearanceForm
activeScheme="dark"
initialValues={settings}
onSubmit={onSubmit}
/>
</div>
);
};
interface PendingUpdateHarnessProps {
initialValues: UserAppearanceSettings;
onSubmit: (update: UpdateUserAppearanceSettingsRequest) => void;
}
const PendingUpdateHarness: FC<PendingUpdateHarnessProps> = ({
initialValues,
onSubmit,
}) => {
const [isUpdating, setIsUpdating] = useState(true);
const [renderCount, setRenderCount] = useState(0);
return (
<div className="flex flex-col gap-6">
<div className="flex gap-2">
<button
type="button"
className="rounded-md border border-solid border-border px-3 py-2"
onClick={() => setRenderCount((current) => current + 1)}
>
Rerender pending update
</button>
<button
type="button"
className="rounded-md border border-solid border-border px-3 py-2"
onClick={() => setIsUpdating(false)}
>
Complete update
</button>
</div>
<div hidden>Render count: {renderCount}</div>
<AppearanceForm
activeScheme="dark"
initialValues={initialValues}
isUpdating={isUpdating}
onSubmit={onSubmit}
/>
</div>
);
};
const meta: Meta<typeof AppearanceForm> = {
title: "pages/UserSettingsPage/AppearanceForm",
component: AppearanceForm,
args: {
onSubmit: (update) =>
Promise.resolve(onUpdateTheme(update.theme_preference)),
activeScheme: "dark",
onSubmit: (update) => Promise.resolve(onUpdateTheme(update)),
},
};
export default meta;
type Story = StoryObj<typeof AppearanceForm>;
export const Example: Story = {
export const SingleDarkDefault: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
},
};
export const SingleLightDefault: Story = {
args: {
activeScheme: "light",
initialValues: { ...baseSettings, theme_preference: "light" },
},
};
export const SelectSingleLightDefault: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
onSubmit: resolvedSubmit(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
await canvas.findByRole("radio", { name: /light default/i }),
);
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
theme_preference: "light",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "geist-mono",
});
});
expect(
await canvas.findByRole("radio", { name: /light default/i }),
).toBeChecked();
},
};
export const SingleDarkProtanDeuter: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark-protan-deuter" },
},
};
export const SingleLightTritan: Story = {
args: {
activeScheme: "light",
initialValues: { ...baseSettings, theme_preference: "light-tritan" },
},
};
export const SyncDefault: Story = {
args: {
initialValues: {
theme_preference: "",
...baseSettings,
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const lightOptions = await canvas.findByRole("group", {
name: "Light theme options",
});
const darkOptions = await canvas.findByRole("group", {
name: "Dark theme options",
});
expect(within(lightOptions).getAllByRole("radio")).toHaveLength(
CONCRETE_THEMES.length,
);
expect(within(darkOptions).getAllByRole("radio")).toHaveLength(
CONCRETE_THEMES.length,
);
},
};
export const SyncActiveLight: Story = {
args: {
activeScheme: "light",
initialValues: {
...baseSettings,
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark",
},
},
};
export const SelectSyncMode: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
onSubmit: resolvedSubmit(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const dropdown = await canvas.findByRole("combobox", {
name: "Theme mode",
});
await user.click(dropdown);
await user.click(
await within(document.body).findByRole("option", {
name: "Sync with system",
}),
);
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
theme_preference: "dark",
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark",
terminal_font: "geist-mono",
});
});
expect(dropdown).toHaveTextContent("Sync with system");
},
};
export const SelectSingleFromSync: Story = {
args: {
activeScheme: "light",
initialValues: {
...baseSettings,
theme_preference: "light-protan-deuter",
theme_mode: "sync",
theme_light: "light-protan-deuter",
theme_dark: "dark-tritan",
},
onSubmit: resolvedSubmit(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const dropdown = await canvas.findByRole("combobox", {
name: "Theme mode",
});
await user.click(dropdown);
await user.click(
await within(document.body).findByRole("option", {
name: "Single theme",
}),
);
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
theme_preference: "light-protan-deuter",
theme_mode: "single",
theme_light: "light-protan-deuter",
theme_dark: "dark-tritan",
terminal_font: "geist-mono",
});
});
expect(dropdown).toHaveTextContent("Single theme");
expect(
await canvas.findByRole("radio", {
name: /light protanopia and deuteranopia/i,
}),
).toBeChecked();
},
};
export const SyncProtanDeuter: Story = {
args: {
initialValues: {
...baseSettings,
theme_mode: "sync",
theme_light: "light-protan-deuter",
theme_dark: "dark-protan-deuter",
},
},
};
export const SyncTritan: Story = {
args: {
initialValues: {
...baseSettings,
theme_mode: "sync",
theme_light: "light-tritan",
theme_dark: "dark-tritan",
},
},
};
export const SyncMixed: Story = {
args: {
initialValues: {
...baseSettings,
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark-tritan",
},
},
};
export const SelectDarkThemeInLightSyncSlot: Story = {
args: {
activeScheme: "light",
initialValues: {
...baseSettings,
theme_preference: "light",
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark",
},
onSubmit: resolvedSubmit(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const lightOptions = await canvas.findByRole("group", {
name: "Light theme options",
});
const darkTritanopia = await within(lightOptions).findByRole("radio", {
name: /dark tritanopia/i,
});
await user.click(darkTritanopia);
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
theme_preference: "dark-tritan",
theme_mode: "sync",
theme_light: "dark-tritan",
theme_dark: "dark",
terminal_font: "geist-mono",
});
});
expect(darkTritanopia).toBeChecked();
},
};
export const SyncHoverPreview: Story = {
args: {
initialValues: {
...baseSettings,
theme_mode: "sync",
theme_light: "light",
theme_dark: "dark",
},
},
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
const lightOptions = await canvas.findByRole("group", {
name: "Light theme options",
});
const radio = await within(lightOptions).findByRole("radio", {
name: "Light tritanopia",
});
const swatch = radio.closest("label");
if (swatch === null) {
throw new Error("Expected the theme radio to be inside a swatch label.");
}
await user.hover(swatch);
},
};
export const SelectTerminalFont: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
onSubmit: resolvedSubmit(),
},
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(await canvas.findByText("Fira Code"));
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledWith({
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "fira-code",
});
});
await user.click(
await canvas.findByRole("radio", { name: /light default/i }),
);
await waitFor(() => {
expect(args.onSubmit).toHaveBeenCalledTimes(2);
});
expect(args.onSubmit).toHaveBeenLastCalledWith({
theme_preference: "light",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "fira-code",
});
expect(
await canvas.findByRole("radio", { name: /light default/i }),
).toBeChecked();
},
};
export const ResyncsWhenInitialValuesChange: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
onSubmit: fn(),
},
render: (args) => (
<ResyncHarness
initialValues={args.initialValues ?? baseSettings}
onSubmit={args.onSubmit ?? onUpdateTheme}
/>
),
play: async ({ canvasElement, args }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
expect(
await canvas.findByRole("radio", { name: /dark default/i }),
).toBeChecked();
await user.click(
await canvas.findByRole("button", { name: "Load light default" }),
);
await waitFor(() => {
expect(
canvas.getByRole("radio", { name: /light default/i }),
).toBeChecked();
});
expect(args.onSubmit).not.toHaveBeenCalled();
},
};
export const PreservesDraftWhileUpdating: Story = {
args: {
initialValues: { ...baseSettings, theme_preference: "dark" },
onSubmit: resolvedSubmit(),
},
render: (args) => (
<PendingUpdateHarness
initialValues={args.initialValues ?? baseSettings}
onSubmit={args.onSubmit ?? onUpdateTheme}
/>
),
play: async ({ canvasElement }) => {
const user = userEvent.setup();
const canvas = within(canvasElement);
await user.click(
await canvas.findByRole("radio", { name: /light default/i }),
);
expect(
await canvas.findByRole("radio", { name: /light default/i }),
).toBeChecked();
await user.click(
await canvas.findByRole("button", {
name: "Rerender pending update",
}),
);
await waitFor(() => {
expect(
canvas.getByRole("radio", { name: /light default/i }),
).toBeChecked();
});
await user.click(
await canvas.findByRole("button", { name: "Complete update" }),
);
await waitFor(() => {
expect(
canvas.getByRole("radio", { name: /dark default/i }),
).toBeChecked();
});
},
};
// Migration paths: settings without the new fields but with a legacy
// `auto-*` theme_preference should render in sync mode on mount.
export const LegacyAutoTritan: Story = {
args: {
initialValues: {
theme_preference: "auto-tritan",
// Legacy rows predate the theme_mode field.
theme_mode: "",
theme_light: "",
theme_dark: "",
terminal_font: "",
},
},
};
export const LegacyAuto: Story = {
args: {
initialValues: {
theme_preference: "auto",
theme_mode: "",
theme_light: "",
theme_dark: "",
@@ -1,25 +1,37 @@
import type { FC } from "react";
import { type FC, useEffect, useId, useState } from "react";
import {
type TerminalFontName,
TerminalFontNames,
type UpdateUserAppearanceSettingsRequest,
type UserAppearanceSettings,
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { PreviewBadge } from "#/components/Badges/Badges";
import { Label } from "#/components/Label/Label";
import { RadioGroup, RadioGroupItem } from "#/components/RadioGroup/RadioGroup";
import {
SettingsHeader,
SettingsHeaderTitle,
} from "#/components/SettingsHeader/SettingsHeader";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "#/components/Select/Select";
import { Spinner } from "#/components/Spinner/Spinner";
import { DEFAULT_THEME } from "#/theme";
import { Section } from "#/pages/UserSettingsPage/Section";
import type { ConcreteThemeName } from "#/theme";
import {
DEFAULT_TERMINAL_FONT,
terminalFontLabels,
terminalFonts,
} from "#/theme/constants";
import { cn } from "#/utils/cn";
import {
draftFromState,
draftToUpdate,
migrateLegacyPreference,
switchToSingle,
type ThemeModeDraft,
} from "#/theme/themeMode";
import { SingleModeSection } from "./SingleModeSection";
import { SyncModeSection } from "./SyncModeSection";
// Display Geist Mono (the default monospace font) first, then the rest
// alphabetically. TerminalFontNames is auto-generated in alphabetical
@@ -29,11 +41,19 @@ const sortedTerminalFontNames = [
...TerminalFontNames.filter((name) => name !== "" && name !== "geist-mono"),
];
interface AppearanceFormValues {
draft: ThemeModeDraft;
terminalFont: TerminalFontName;
}
type AppearanceThemeMode = ThemeModeDraft["mode"];
interface AppearanceFormProps {
isUpdating?: boolean;
error?: unknown;
initialValues: UpdateUserAppearanceSettingsRequest;
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => Promise<unknown>;
initialValues: UserAppearanceSettings;
activeScheme: "dark" | "light"; // The OS color scheme currently in effect
onSubmit: (values: UpdateUserAppearanceSettingsRequest) => void;
}
export const AppearanceForm: FC<AppearanceFormProps> = ({
@@ -41,92 +61,180 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
error,
onSubmit,
initialValues,
activeScheme,
}) => {
const currentTheme = initialValues.theme_preference || DEFAULT_THEME;
const currentTerminalFont =
initialValues.terminal_font || DEFAULT_TERMINAL_FONT;
const [values, setValues] = useState(() => toFormValues(initialValues));
const themeModeId = useId();
const fontGroupId = useId();
const fontGroupLabelId = `${fontGroupId}-label`;
const fontGroupName = `${fontGroupId}-fonts`;
const singleThemeGroupName = `${themeModeId}-single`;
const syncThemeGroupNamePrefix = `${themeModeId}-sync`;
const onChangeTheme = async (theme: string) => {
const {
theme_preference,
theme_mode,
theme_light,
theme_dark,
terminal_font,
} = initialValues;
useEffect(() => {
if (isUpdating) {
return;
}
await onSubmit({
theme_preference: theme,
theme_mode: "",
theme_light: "",
theme_dark: "",
terminal_font: currentTerminalFont,
const next = toFormValues({
theme_preference,
theme_mode,
theme_light,
theme_dark,
terminal_font,
});
setValues(next);
}, [
isUpdating,
theme_preference,
theme_mode,
theme_light,
theme_dark,
terminal_font,
]);
const { draft, terminalFont } = values;
const submit = (next: AppearanceFormValues) => {
setValues(next);
onSubmit(toUpdateRequest(next, activeScheme));
};
const onChangeMode = (mode: AppearanceThemeMode) => {
if (mode === draft.mode) {
return;
}
const next: ThemeModeDraft =
mode === "single"
? {
mode: "single",
single: switchToSingle(
{ mode: "sync", light: draft.light, dark: draft.dark },
activeScheme,
).theme,
light: draft.light,
dark: draft.dark,
}
: {
mode: "sync",
single: draft.single,
light: draft.light,
dark: draft.dark,
};
submit({ draft: next, terminalFont });
};
const onSelectSyncSlot = (
scheme: "light" | "dark",
theme: ConcreteThemeName,
) => {
const next: ThemeModeDraft =
scheme === "light"
? { ...draft, light: theme }
: { ...draft, dark: theme };
submit({ draft: next, terminalFont });
};
const onSelectSingle = (theme: ConcreteThemeName) => {
submit({
draft: { ...draft, single: theme, mode: "single" },
terminalFont,
});
};
const onChangeTerminalFont = async (terminalFont: TerminalFontName) => {
if (isUpdating) {
return;
}
await onSubmit({
theme_preference: currentTheme,
theme_mode: "",
theme_light: "",
theme_dark: "",
terminal_font: terminalFont,
});
const onChangeTerminalFont = (nextTerminalFont: TerminalFontName) => {
submit({ draft, terminalFont: nextTerminalFont });
};
return (
<form className="flex flex-col gap-12">
<form onSubmit={(event) => event.preventDefault()}>
{Boolean(error) && <ErrorAlert error={error} />}
<div>
<SettingsHeader>
<SettingsHeaderTitle>
<Section
title={
<div className="flex flex-row items-center gap-2">
<span>Theme</span>
<Spinner loading={isUpdating} size="sm" />
</SettingsHeaderTitle>
</SettingsHeader>
</div>
}
layout="fluid"
className="mb-12"
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor={themeModeId} className="text-sm font-medium">
Theme mode
</Label>
<div className="flex items-center gap-4">
<Select
value={draft.mode}
onValueChange={(value) => {
if (isThemeMode(value)) {
onChangeMode(value);
}
}}
>
<SelectTrigger
id={themeModeId}
className="w-48 text-content-primary"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sync">Sync with system</SelectItem>
<SelectItem value="single">Single theme</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex flex-row flex-wrap gap-4">
<AutoThemePreviewButton
displayName="Auto"
active={currentTheme === "auto"}
themes={["dark", "light"]}
onSelect={() => onChangeTheme("auto")}
/>
<ThemePreviewButton
displayName="Dark"
active={currentTheme === "dark"}
theme="dark"
onSelect={() => onChangeTheme("dark")}
/>
<ThemePreviewButton
displayName="Light"
active={currentTheme === "light"}
theme="light"
onSelect={() => onChangeTheme("light")}
/>
{draft.mode === "sync" ? (
<SyncModeSection
light={draft.light}
dark={draft.dark}
activeScheme={activeScheme}
namePrefix={syncThemeGroupNamePrefix}
onSelect={onSelectSyncSlot}
/>
) : (
<SingleModeSection
selected={draft.single}
name={singleThemeGroupName}
onSelect={onSelectSingle}
/>
)}
</div>
</div>
</Section>
<div>
<SettingsHeader>
<SettingsHeaderTitle hierarchy="secondary">
<span id="fonts-radio-buttons-group-label">Terminal Font</span>
<Section
title={
<div className="flex flex-row items-center gap-2">
<span id={fontGroupLabelId}>Terminal Font</span>
<Spinner loading={isUpdating} size="sm" />
</SettingsHeaderTitle>
</SettingsHeader>
</div>
}
layout="fluid"
>
<RadioGroup
aria-labelledby="fonts-radio-buttons-group-label"
defaultValue={currentTerminalFont}
name="fonts-radio-buttons-group"
aria-labelledby={fontGroupLabelId}
value={terminalFont}
name={fontGroupName}
onValueChange={(value) =>
onChangeTerminalFont(toTerminalFontName(value))
}
>
{sortedTerminalFontNames.map((name) => (
<div key={name} className="flex items-center space-x-2">
<RadioGroupItem value={name} id={name} />
<RadioGroupItem value={name} id={`${fontGroupId}-${name}`} />
<Label
htmlFor={name}
htmlFor={`${fontGroupId}-${name}`}
className="cursor-pointer font-normal"
style={{ fontFamily: terminalFonts[name] }}
>
@@ -135,173 +243,34 @@ export const AppearanceForm: FC<AppearanceFormProps> = ({
</div>
))}
</RadioGroup>
</div>
</Section>
</form>
);
};
function toFormValues(settings: UserAppearanceSettings): AppearanceFormValues {
return {
draft: draftFromState(migrateLegacyPreference(settings), {
light: settings.theme_light,
dark: settings.theme_dark,
}),
terminalFont: settings.terminal_font || DEFAULT_TERMINAL_FONT,
};
}
function toUpdateRequest(
values: AppearanceFormValues,
activeScheme: "dark" | "light",
): UpdateUserAppearanceSettingsRequest {
return draftToUpdate(values.draft, values.terminalFont, activeScheme);
}
function isThemeMode(value: string): value is AppearanceThemeMode {
return value === "sync" || value === "single";
}
function toTerminalFontName(value: string): TerminalFontName {
return TerminalFontNames.includes(value as TerminalFontName)
? (value as TerminalFontName)
: "";
}
type ThemeMode = "dark" | "light";
interface AutoThemePreviewButtonProps extends Omit<ThemePreviewProps, "theme"> {
themes: [ThemeMode, ThemeMode];
onSelect?: () => void;
}
const AutoThemePreviewButton: FC<AutoThemePreviewButtonProps> = ({
active,
preview,
className,
displayName,
themes,
onSelect,
}) => {
const [leftTheme, rightTheme] = themes;
return (
<>
<input
type="radio"
name="theme"
id={displayName}
value={displayName}
checked={active}
onChange={onSelect}
className="sr-only"
/>
<label
htmlFor={displayName}
className={cn("relative cursor-pointer", className)}
>
<ThemePreview
className="absolute"
style={{
// Slightly past the bounding box to avoid cutting off the outline
clipPath: "polygon(-5% -5%, 50% -5%, 50% 105%, -5% 105%)",
}}
active={active}
preview={preview}
displayName={displayName}
theme={leftTheme}
/>
<ThemePreview
active={active}
preview={preview}
displayName={displayName}
theme={rightTheme}
style={{
// Slightly past the bounding box to avoid cutting off the outline
clipPath: "polygon(50% -5%, 105% -5%, 105% 105%, 50% 105%)",
}}
/>
</label>
</>
);
};
interface ThemePreviewButtonProps extends ThemePreviewProps {
onSelect?: () => void;
}
const ThemePreviewButton: FC<ThemePreviewButtonProps> = ({
active,
preview,
className,
displayName,
theme,
onSelect,
}) => {
return (
<>
<input
type="radio"
name="theme"
id={displayName}
value={displayName}
checked={active}
onChange={onSelect}
className="sr-only"
/>
<label htmlFor={displayName} className={cn("cursor-pointer", className)}>
<ThemePreview
active={active}
preview={preview}
displayName={displayName}
theme={theme}
/>
</label>
</>
);
};
interface ThemePreviewProps {
active?: boolean;
preview?: boolean;
className?: string;
style?: React.CSSProperties;
displayName: string;
theme: ThemeMode;
}
const ThemePreview: FC<ThemePreviewProps> = ({
active,
preview,
className,
style,
displayName,
theme,
}) => {
return (
<div className={theme}>
<div
className={cn(
"w-56 overflow-clip rounded-md border border-border border-solid bg-surface-primary text-content-primary select-none",
active && "outline outline-2 outline-content-link",
className,
)}
style={style}
>
<div className="bg-surface-primary text-content-primary">
<div className="bg-surface-primary flex items-center justify-between px-2.5 py-1.5 mb-2 border-0 border-b border-border border-solid">
<div className="flex items-center gap-1.5">
<div className="bg-content-primary h-1.5 w-5 rounded" />
<div className="bg-content-secondary h-1.5 w-5 rounded" />
<div className="bg-content-secondary h-1.5 w-5 rounded" />
</div>
<div className="flex items-center gap-1.5">
<div className="bg-green-400 h-1.5 w-3 rounded" />
<div className="bg-content-primary h-2 w-2 rounded-full" />
</div>
</div>
<div className="w-32 mx-auto">
<div className="bg-content-primary h-2 w-11 rounded mb-1.5" />
<div className="border border-solid rounded-t overflow-clip">
<div className="bg-surface-secondary h-2.5 -m-px" />
<div className="h-4 border-0 border-t border-border border-solid">
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
</div>
<div className="h-4 border-0 border-t border-border border-solid">
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
</div>
<div className="h-4 border-0 border-t border-border border-solid">
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
</div>
<div className="h-4 border-0 border-t border-border border-solid">
<div className="bg-content-disabled h-1.5 w-8 rounded mt-1 ml-1" />
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between border-0 border-t border-border border-solid px-3 py-1 text-sm">
<span>{displayName}</span>
{preview && <PreviewBadge />}
</div>
</div>
</div>
);
};
@@ -1,127 +1,60 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { API } from "#/api/api";
import { MockUserOwner } from "#/testHelpers/entities";
import { renderWithAuth } from "#/testHelpers/renderHelpers";
import AppearancePage from "./AppearancePage";
import { act, renderHook } from "@testing-library/react";
import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated";
import { useQueuedAppearanceSubmit } from "./AppearancePage";
describe("appearance page", () => {
it("does nothing when selecting current theme", async () => {
renderWithAuth(<AppearancePage />);
const updateRequest = (
overrides: Partial<UpdateUserAppearanceSettingsRequest> = {},
): UpdateUserAppearanceSettingsRequest => ({
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "geist-mono",
...overrides,
});
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUserOwner,
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
terminal_font: "fira-code",
describe("useQueuedAppearanceSubmit", () => {
it("submits one request at a time and keeps only the latest pending update", () => {
const mutations: Array<{
values: UpdateUserAppearanceSettingsRequest;
onSettled: () => void;
}> = [];
const mutate = vi.fn(
(
values: UpdateUserAppearanceSettingsRequest,
options: { onSettled: () => void },
) => {
mutations.push({ values, onSettled: options.onSettled });
},
);
const { result } = renderHook(() => useQueuedAppearanceSubmit(mutate));
const first = updateRequest({ terminal_font: "fira-code" });
const overwritten = updateRequest({ theme_preference: "dark-tritan" });
const latest = updateRequest({ theme_preference: "light" });
act(() => {
result.current(first);
result.current(overwritten);
result.current(latest);
});
const dark = await screen.findByText("Dark");
await userEvent.click(dark);
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutations[0]?.values).toEqual(first);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(0);
});
it("changes theme to light", async () => {
renderWithAuth(<AppearancePage />);
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUserOwner,
terminal_font: "geist-mono",
theme_preference: "light",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
act(() => {
mutations[0]?.onSettled();
});
const light = await screen.findByText("Light");
await userEvent.click(light);
expect(mutate).toHaveBeenCalledTimes(2);
expect(mutations[1]?.values).toEqual(latest);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
expect.objectContaining({
terminal_font: "geist-mono",
theme_preference: "light",
}),
);
});
it("changes font to fira code", async () => {
renderWithAuth(<AppearancePage />);
vi.spyOn(API, "updateAppearanceSettings").mockResolvedValueOnce({
...MockUserOwner,
terminal_font: "fira-code",
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
act(() => {
mutations[1]?.onSettled();
result.current(overwritten);
});
const firaCode = await screen.findByText("Fira Code");
await userEvent.click(firaCode);
// Check if the API was called correctly
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
expect.objectContaining({
terminal_font: "fira-code",
theme_preference: "dark",
}),
);
});
it("changes font to fira code, then back to geist mono", async () => {
renderWithAuth(<AppearancePage />);
// given
vi.spyOn(API, "updateAppearanceSettings")
.mockResolvedValueOnce({
...MockUserOwner,
terminal_font: "fira-code",
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
})
.mockResolvedValueOnce({
...MockUserOwner,
terminal_font: "geist-mono",
theme_preference: "dark",
theme_mode: "single",
theme_light: "light",
theme_dark: "dark",
});
// when
const firaCode = await screen.findByText("Fira Code");
await userEvent.click(firaCode);
// then
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(1);
expect(API.updateAppearanceSettings).toHaveBeenCalledWith(
expect.objectContaining({
terminal_font: "fira-code",
theme_preference: "dark",
}),
);
// when
const geistMono = await screen.findByText("Geist Mono");
await userEvent.click(geistMono);
// then
expect(API.updateAppearanceSettings).toHaveBeenCalledTimes(2);
expect(API.updateAppearanceSettings).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
terminal_font: "geist-mono",
theme_preference: "dark",
}),
);
expect(mutate).toHaveBeenCalledTimes(3);
expect(mutations[2]?.values).toEqual(overwritten);
});
});
@@ -1,14 +1,52 @@
import type { FC } from "react";
import { type FC, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "react-query";
import {
appearanceSettings,
updateAppearanceSettings,
} from "#/api/queries/users";
import type { UpdateUserAppearanceSettingsRequest } from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Loader } from "#/components/Loader/Loader";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import { usePreferredColorScheme } from "#/theme/usePreferredColorScheme";
import { AppearanceForm } from "./AppearanceForm";
type MutateAppearanceSettings = (
values: UpdateUserAppearanceSettingsRequest,
options: { onSettled: () => void },
) => void;
export const useQueuedAppearanceSubmit = (mutate: MutateAppearanceSettings) => {
const submitInFlightRef = useRef(false);
const pendingSubmitRef = useRef<UpdateUserAppearanceSettingsRequest | null>(
null,
);
const fireSubmit = (values: UpdateUserAppearanceSettingsRequest) => {
submitInFlightRef.current = true;
mutate(values, {
onSettled: () => {
const queued = pendingSubmitRef.current;
pendingSubmitRef.current = null;
if (queued !== null) {
fireSubmit(queued);
return;
}
submitInFlightRef.current = false;
},
});
};
return (values: UpdateUserAppearanceSettingsRequest) => {
if (submitInFlightRef.current) {
pendingSubmitRef.current = values;
return;
}
fireSubmit(values);
};
};
const AppearancePage: FC = () => {
const queryClient = useQueryClient();
const updateAppearanceSettingsMutation = useMutation(
@@ -19,6 +57,14 @@ const AppearancePage: FC = () => {
const appearanceSettingsQuery = useQuery(
appearanceSettings(metadata.userAppearance),
);
const osColorScheme = usePreferredColorScheme();
const submitAppearanceSettings = useQueuedAppearanceSubmit(
(values, options) => {
updateAppearanceSettingsMutation.mutate(values, {
onSettled: options.onSettled,
});
},
);
if (appearanceSettingsQuery.isLoading) {
return <Loader />;
@@ -32,14 +78,9 @@ const AppearancePage: FC = () => {
<AppearanceForm
isUpdating={updateAppearanceSettingsMutation.isPending}
error={updateAppearanceSettingsMutation.error}
initialValues={{
theme_preference: appearanceSettingsQuery.data.theme_preference,
theme_mode: appearanceSettingsQuery.data.theme_mode,
theme_light: appearanceSettingsQuery.data.theme_light,
theme_dark: appearanceSettingsQuery.data.theme_dark,
terminal_font: appearanceSettingsQuery.data.terminal_font,
}}
onSubmit={updateAppearanceSettingsMutation.mutateAsync}
initialValues={appearanceSettingsQuery.data}
activeScheme={osColorScheme}
onSubmit={submitAppearanceSettings}
/>
);
};
@@ -6,6 +6,7 @@ import { DARK_THEMES, LIGHT_THEMES, THEME_COPY } from "./themeCopy";
interface SingleModeSectionProps {
selected: ConcreteThemeName;
name?: string;
onSelect: (theme: ConcreteThemeName) => void;
}
@@ -16,6 +17,7 @@ const SINGLE_MODE_ORDER: ConcreteThemeName[] = [
export const SingleModeSection: FC<SingleModeSectionProps> = ({
selected,
name = "theme-single",
onSelect,
}) => {
return (
@@ -25,6 +27,7 @@ export const SingleModeSection: FC<SingleModeSectionProps> = ({
{SINGLE_MODE_ORDER.map((theme) => (
<SingleTile
key={theme}
name={name}
theme={theme}
selected={theme === selected}
onSelect={() => onSelect(theme)}
@@ -36,12 +39,18 @@ export const SingleModeSection: FC<SingleModeSectionProps> = ({
};
interface SingleTileProps {
name: string;
theme: ConcreteThemeName;
selected: boolean;
onSelect: () => void;
}
const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
const SingleTile: FC<SingleTileProps> = ({
name,
theme,
selected,
onSelect,
}) => {
const copy = THEME_COPY[theme];
return (
<label
@@ -53,7 +62,7 @@ const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
>
<input
type="radio"
name="theme-single"
name={name}
value={theme}
checked={selected}
onChange={onSelect}
@@ -66,8 +75,8 @@ const SingleTile: FC<SingleTileProps> = ({ theme, selected, onSelect }) => {
<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",
"mt-0.5 flex size-4 shrink-0 items-center justify-center rounded-full border border-solid border-border bg-surface-primary",
selected && "border-border-secondary",
)}
>
{selected && (
@@ -11,6 +11,7 @@ interface SyncModeSectionProps {
light: ConcreteThemeName;
dark: ConcreteThemeName;
activeScheme: "dark" | "light"; // The OS color scheme currently in effect
namePrefix?: string;
onSelect: (scheme: "light" | "dark", theme: ConcreteThemeName) => void;
}
@@ -18,6 +19,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
light,
dark,
activeScheme,
namePrefix = "theme-sync",
onSelect,
}) => {
return (
@@ -27,6 +29,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
scheme="light"
selected={light}
active={activeScheme === "light"}
name={`${namePrefix}-light`}
onSelect={(theme) => onSelect("light", theme)}
/>
<SyncCard
@@ -34,6 +37,7 @@ export const SyncModeSection: FC<SyncModeSectionProps> = ({
scheme="dark"
selected={dark}
active={activeScheme === "dark"}
name={`${namePrefix}-dark`}
onSelect={(theme) => onSelect("dark", theme)}
/>
</div>
@@ -44,6 +48,7 @@ interface SyncCardProps {
scheme: "light" | "dark";
selected: ConcreteThemeName;
active: boolean;
name: string;
onSelect: (theme: ConcreteThemeName) => void;
}
@@ -51,6 +56,7 @@ const SyncCard: FC<SyncCardProps> = ({
scheme,
selected,
active,
name,
onSelect,
}) => {
const [previewTheme, setPreviewTheme] = useState<
@@ -94,7 +100,7 @@ const SyncCard: FC<SyncCardProps> = ({
{SYNC_MODE_THEMES.map((theme) => (
<ThemeSwatch
key={theme}
name={`theme-sync-${scheme}`}
name={name}
theme={theme}
selected={theme === selected}
onSelect={() => onSelect(theme)}