diff --git a/src/features/settings/__tests__/themePreview.test.tsx b/src/features/settings/__tests__/themePreview.test.tsx
new file mode 100644
index 000000000..38f04e116
--- /dev/null
+++ b/src/features/settings/__tests__/themePreview.test.tsx
@@ -0,0 +1,82 @@
+import { act, fireEvent, render, screen, within } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { useProject } from "@/platform/project";
+import { applyThemeAttributes, currentThemeId, useThemeOverride } from "@/workbench/theme";
+import { THEME_PREVIEW_DELAY } from "@/workbench/quickSwitch";
+import { DEFAULT_SETTINGS } from "../model/schema";
+import { openSettings, resetPage } from "../model/staging";
+import { PageContext } from "../components/context";
+import { BEHAVIOR_PAGES } from "../pages/behavior";
+
+const root = document.documentElement;
+const shown = () => [root.dataset.theme, root.dataset.colorTheme ?? null];
+
+function renderThemeRow() {
+ const page = BEHAVIOR_PAGES.find((p) => p.id === "appearance")!;
+ const option = page.sections!.flatMap((s) => s.options).find((o) => o.kind === "custom" && o.label === "Theme");
+ if (option?.kind !== "custom") throw new Error("no Theme row");
+ const Row = option.component;
+ return render(
+
+
+ ,
+ );
+}
+
+const pick = (label: string) => {
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
+ fireEvent.click(within(screen.getByRole("listbox")).getByText(label));
+};
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ useThemeOverride.setState({ theme: null });
+ useProject.setState({ settings: structuredClone(DEFAULT_SETTINGS), project: null });
+ applyThemeAttributes(currentThemeId());
+ openSettings("appearance");
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ useThemeOverride.setState({ theme: null });
+ applyThemeAttributes(currentThemeId());
+});
+
+describe("Settings › Appearance › Theme preview", () => {
+ it("shows a picked theme in this window until Apply", () => {
+ renderThemeRow();
+ pick("Solarized Dark");
+ expect(shown()).toEqual(["dark", "solarized-dark"]);
+ });
+
+ it("goes back to the selected theme when it is picked again before the hover preview shows", () => {
+ renderThemeRow();
+ expect(shown()).toEqual(["light", null]);
+ pick("Solarized Dark");
+ expect(shown()).toEqual(["dark", "solarized-dark"]);
+ // Clicked quicker than the hover delay: the list itself never previewed anything.
+ act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY / 2));
+ pick("Islands Light");
+ act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
+ expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light");
+ expect(shown()).toEqual(["light", null]);
+ });
+
+ it("goes back to the selected theme after hovering it long enough", () => {
+ renderThemeRow();
+ pick("Solarized Dark");
+ fireEvent.click(screen.getByRole("button", { name: "Theme" }));
+ fireEvent.pointerEnter(within(screen.getByRole("listbox")).getByText("Islands Light"));
+ act(() => vi.advanceTimersByTime(THEME_PREVIEW_DELAY));
+ fireEvent.click(within(screen.getByRole("listbox")).getByText("Islands Light"));
+ expect(shown()).toEqual(["light", null]);
+ });
+
+ it("goes back to the selected theme when the page is reset", () => {
+ renderThemeRow();
+ pick("Solarized Dark");
+ act(() => resetPage("appearance"));
+ expect(screen.getByRole("button", { name: "Theme" })).toHaveTextContent("Islands Light");
+ expect(shown()).toEqual(["light", null]);
+ });
+});
diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx
index 23a4ff5be..f7a88d0a0 100644
--- a/src/features/settings/pages/behavior.tsx
+++ b/src/features/settings/pages/behavior.tsx
@@ -53,6 +53,9 @@ function Indented({ children }: { children: ReactNode }) {
// ---------------------------------------------------------------------------
// Appearance
+/** Shows the theme picked in Settings in this window until Apply, or the selected one again. */
+const showPicked = (id: ConcreteTheme) => previewTheme(id !== currentThemeChoice() ? id : null);
+
function ThemeRow() {
const [theme, setTheme] = useSetting("appearance.theme");
const [sync, setSync] = useSetting("appearance.syncWithOs");
@@ -72,11 +75,16 @@ function ThemeRow() {
},
[],
);
+ // The staged theme can also change without the list, e.g. by Reset.
+ useEffect(() => {
+ if (previewed.current) showPicked(shown);
+ }, [shown]);
const preview = (id: ConcreteTheme | null) => {
previewed.current = true;
if (id !== null) return picker.preview(id);
+ // The picker takes back only its own hover preview, not a theme picked here earlier.
picker.restore();
- if (shown !== currentThemeChoice()) previewTheme(shown);
+ showPicked(shown);
};
return (