From c061ad13ce246791263b3dab9636d7904aa01514 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 25 Apr 2026 22:39:18 +0800 Subject: [PATCH] fix: default theme setting not persisting across sessions (#98) Three disconnected systems caused the theme to never apply from server settings: the DEFAULT_THEME env var was parsed but never seeded to the database, the settings store ignored defaultTheme from the API, and the settings dialog wrote to the DB without updating the active theme store. - Seed DEFAULT_THEME and DEFAULT_LOCALE env vars into the settings table on first startup (ensureDefaultSettings in index.ts) - Add applyServerDefault() to theme store that applies the server's default theme only when the user hasn't made an explicit choice - Extract defaultTheme from the settings API response and apply it on fresh sessions (no localStorage preference) - Apply theme immediately when admin saves settings - Allow "system" as a valid DEFAULT_THEME env var value --- apps/api/src/index.ts | 15 +++++ apps/api/src/lib/env.ts | 2 +- .../components/settings/settings-dialog.tsx | 5 ++ apps/web/src/stores/settings-store.ts | 14 ++++ apps/web/src/stores/theme-store.ts | 18 ++++- tests/unit/web/zustand-stores.test.ts | 67 ++++++++++++++++++- 6 files changed, 117 insertions(+), 4 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 98ab097f..2f7a6c5d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -53,6 +53,21 @@ function ensureInstanceId() { } ensureInstanceId(); + +function ensureDefaultSettings() { + const defaults: Record = { + defaultTheme: env.DEFAULT_THEME, + defaultLocale: env.DEFAULT_LOCALE, + }; + for (const [key, value] of Object.entries(defaults)) { + const existing = db.select().from(schema.settings).where(eq(schema.settings.key, key)).get(); + if (!existing) { + db.insert(schema.settings).values({ key, value }).run(); + } + } +} + +ensureDefaultSettings(); await initAnalytics(); // Mark any jobs left in processing/queued from a previous unclean shutdown diff --git a/apps/api/src/lib/env.ts b/apps/api/src/lib/env.ts index 6f58678a..e3d009d5 100644 --- a/apps/api/src/lib/env.ts +++ b/apps/api/src/lib/env.ts @@ -24,7 +24,7 @@ const envSchema = z.object({ DB_PATH: z.string().default("./data/snapotter.db"), FILES_STORAGE_PATH: z.string().default("./data/files"), WORKSPACE_PATH: z.string().default("./tmp/workspace"), - DEFAULT_THEME: z.enum(["light", "dark"]).default("light"), + DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"), DEFAULT_LOCALE: z.string().default("en"), APP_NAME: z.string().default("snapotter"), CORS_ORIGIN: z.string().default(""), diff --git a/apps/web/src/components/settings/settings-dialog.tsx b/apps/web/src/components/settings/settings-dialog.tsx index dc758244..a70b7fe9 100644 --- a/apps/web/src/components/settings/settings-dialog.tsx +++ b/apps/web/src/components/settings/settings-dialog.tsx @@ -32,6 +32,7 @@ import { apiDelete, apiGet, apiPost, apiPut, clearToken, formatHeaders } from "@ import { cn, copyToClipboard } from "@/lib/utils"; import { useAnalyticsStore } from "@/stores/analytics-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useThemeStore } from "@/stores/theme-store"; import { OtterLogo } from "../common/otter-logo"; import { AiFeaturesSection } from "./ai-features-section"; @@ -367,6 +368,10 @@ function SystemSection() { setSaveMsg(null); try { await apiPut("/v1/settings", settings); + if (settings.defaultTheme) { + const theme = settings.defaultTheme as "light" | "dark" | "system"; + useThemeStore.getState().setTheme(theme); + } setSaveMsg("Settings saved."); } catch { setSaveMsg("Failed to save settings."); diff --git a/apps/web/src/stores/settings-store.ts b/apps/web/src/stores/settings-store.ts index 0fbbc5af..1a1abcd8 100644 --- a/apps/web/src/stores/settings-store.ts +++ b/apps/web/src/stores/settings-store.ts @@ -1,18 +1,25 @@ import { create } from "zustand"; import { apiGet } from "@/lib/api"; +import { useThemeStore } from "./theme-store"; + +type Theme = "light" | "dark" | "system"; interface SettingsState { disabledTools: string[]; experimentalEnabled: boolean; defaultToolView: "sidebar" | "fullscreen"; + defaultTheme: Theme; loaded: boolean; fetch: () => Promise; } +const VALID_THEMES = new Set(["light", "dark", "system"]); + export const useSettingsStore = create((set, get) => ({ disabledTools: [], experimentalEnabled: false, defaultToolView: "sidebar", + defaultTheme: "light", loaded: false, fetch: async () => { @@ -22,12 +29,19 @@ export const useSettingsStore = create((set, get) => ({ settings: Record; }>("/v1/settings"); + const defaultTheme = VALID_THEMES.has(data.settings.defaultTheme) + ? (data.settings.defaultTheme as Theme) + : "light"; + set({ disabledTools: data.settings.disabledTools ? JSON.parse(data.settings.disabledTools) : [], experimentalEnabled: data.settings.enableExperimentalTools === "true", defaultToolView: data.settings.defaultToolView === "fullscreen" ? "fullscreen" : "sidebar", + defaultTheme, loaded: true, }); + + useThemeStore.getState().applyServerDefault(defaultTheme); } catch { set({ loaded: true }); } diff --git a/apps/web/src/stores/theme-store.ts b/apps/web/src/stores/theme-store.ts index 6ae7bbaf..c56d3e35 100644 --- a/apps/web/src/stores/theme-store.ts +++ b/apps/web/src/stores/theme-store.ts @@ -3,10 +3,13 @@ import { persist } from "zustand/middleware"; type Theme = "light" | "dark" | "system"; +const USER_THEME_KEY = "snapotter-theme-user-set"; + interface ThemeStore { theme: Theme; setTheme: (theme: Theme) => void; resolvedTheme: "light" | "dark"; + applyServerDefault: (theme: Theme) => void; } function getSystemTheme(): "light" | "dark" { @@ -14,14 +17,25 @@ function getSystemTheme(): "light" | "dark" { return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } +function applyTheme(theme: Theme): "light" | "dark" { + const resolved = theme === "system" ? getSystemTheme() : theme; + document.documentElement.classList.toggle("dark", resolved === "dark"); + return resolved; +} + export const useThemeStore = create()( persist( (set) => ({ theme: "light" as Theme, resolvedTheme: "light" as const, setTheme: (theme) => { - const resolved = theme === "system" ? getSystemTheme() : theme; - document.documentElement.classList.toggle("dark", resolved === "dark"); + const resolved = applyTheme(theme); + localStorage.setItem(USER_THEME_KEY, "1"); + set({ theme, resolvedTheme: resolved }); + }, + applyServerDefault: (theme) => { + if (localStorage.getItem(USER_THEME_KEY)) return; + const resolved = applyTheme(theme); set({ theme, resolvedTheme: resolved }); }, }), diff --git a/tests/unit/web/zustand-stores.test.ts b/tests/unit/web/zustand-stores.test.ts index 6ec357ee..a2695508 100644 --- a/tests/unit/web/zustand-stores.test.ts +++ b/tests/unit/web/zustand-stores.test.ts @@ -858,8 +858,8 @@ import { useThemeStore } from "@/stores/theme-store"; describe("useThemeStore", () => { beforeEach(() => { - // Reset to initial values useThemeStore.setState({ theme: "light", resolvedTheme: "light" }); + localStorage.removeItem("snapotter-theme-user-set"); }); it("has correct initial state", () => { @@ -890,6 +890,31 @@ describe("useThemeStore", () => { // matchMedia mock returns matches: false, so system resolves to "light" expect(s.resolvedTheme).toBe("light"); }); + + it("setTheme sets user-set flag in localStorage", () => { + useThemeStore.getState().setTheme("dark"); + expect(localStorage.getItem("snapotter-theme-user-set")).toBe("1"); + }); + + it("applyServerDefault applies theme when no user-set flag", () => { + useThemeStore.getState().applyServerDefault("dark"); + const s = useThemeStore.getState(); + expect(s.theme).toBe("dark"); + expect(s.resolvedTheme).toBe("dark"); + }); + + it("applyServerDefault skips when user-set flag exists", () => { + useThemeStore.getState().setTheme("light"); + useThemeStore.getState().applyServerDefault("dark"); + const s = useThemeStore.getState(); + expect(s.theme).toBe("light"); + expect(s.resolvedTheme).toBe("light"); + }); + + it("applyServerDefault does not set user-set flag", () => { + useThemeStore.getState().applyServerDefault("dark"); + expect(localStorage.getItem("snapotter-theme-user-set")).toBeNull(); + }); }); // ========================================================================== @@ -1371,8 +1396,10 @@ describe("useSettingsStore", () => { disabledTools: [], experimentalEnabled: false, defaultToolView: "sidebar", + defaultTheme: "light", loaded: false, }); + localStorage.removeItem("snapotter-theme-user-set"); mockApiGet.mockReset(); }); @@ -1381,6 +1408,7 @@ describe("useSettingsStore", () => { expect(s.disabledTools).toEqual([]); expect(s.experimentalEnabled).toBe(false); expect(s.defaultToolView).toBe("sidebar"); + expect(s.defaultTheme).toBe("light"); expect(s.loaded).toBe(false); }); @@ -1390,6 +1418,7 @@ describe("useSettingsStore", () => { disabledTools: JSON.stringify(["resize", "crop"]), enableExperimentalTools: "true", defaultToolView: "fullscreen", + defaultTheme: "dark", }, }); @@ -1399,9 +1428,44 @@ describe("useSettingsStore", () => { expect(s.disabledTools).toEqual(["resize", "crop"]); expect(s.experimentalEnabled).toBe(true); expect(s.defaultToolView).toBe("fullscreen"); + expect(s.defaultTheme).toBe("dark"); expect(s.loaded).toBe(true); }); + it("fetch applies server default theme when no user preference", async () => { + mockApiGet.mockResolvedValueOnce({ + settings: { defaultTheme: "dark" }, + }); + + await useSettingsStore.getState().fetch(); + + const theme = useThemeStore.getState(); + expect(theme.theme).toBe("dark"); + expect(theme.resolvedTheme).toBe("dark"); + }); + + it("fetch does not override user theme preference", async () => { + useThemeStore.getState().setTheme("light"); + + mockApiGet.mockResolvedValueOnce({ + settings: { defaultTheme: "dark" }, + }); + + await useSettingsStore.getState().fetch(); + + const theme = useThemeStore.getState(); + expect(theme.theme).toBe("light"); + }); + + it("fetch defaults defaultTheme to light for invalid values", async () => { + mockApiGet.mockResolvedValueOnce({ + settings: { defaultTheme: "invalid" }, + }); + + await useSettingsStore.getState().fetch(); + expect(useSettingsStore.getState().defaultTheme).toBe("light"); + }); + it("fetch skips when already loaded", async () => { useSettingsStore.setState({ loaded: true }); await useSettingsStore.getState().fetch(); @@ -1419,6 +1483,7 @@ describe("useSettingsStore", () => { expect(s.disabledTools).toEqual([]); expect(s.experimentalEnabled).toBe(false); expect(s.defaultToolView).toBe("sidebar"); + expect(s.defaultTheme).toBe("light"); expect(s.loaded).toBe(true); });