summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:45:36 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:45:36 +0900
commit6c377fba7d516ea89ef2d906a40785a997299b0c (patch)
tree32e4dde45fbb4f57680e92050798aa65f15e5a6f /packages/frontend/tests
parent60999dc48d8c06a10ff8f5b3f6edb1d220fd85ca (diff)
downloaddispatch-6c377fba7d516ea89ef2d906a40785a997299b0c.tar.gz
dispatch-6c377fba7d516ea89ef2d906a40785a997299b0c.zip
fix(theme): consolidate boot apply and Settings picker into shared module
Gemini review surfaced that App.svelte (onMount theme apply) and SettingsPanel.svelte (theme <select>) hand-rolled their own defaults and could disagree: - App.svelte only set data-theme if localStorage had a value, so on a fresh install daisyUI fell back to the first theme in app.css (light). - SettingsPanel.svelte hardcoded a UI default of "dark". Result: a first-time user saw a light app but a Settings panel that claimed "dark" was selected. Picking *any* value in the dropdown was the only way to reconcile reality with the UI. This commit: - Adds packages/frontend/src/lib/theme.ts as the single source of truth: THEMES list, Theme type, THEME_STORAGE_KEY, DEFAULT_THEME, plus loadStoredTheme() and applyTheme() that handle SSR / private-mode / bad-value cases. - Rewires App.svelte's onMount to call applyTheme(loadStoredTheme()), so the boot apply always writes a known good theme to the DOM (even on fresh installs), matching what Settings will show. - Rewires SettingsPanel.svelte's picker to use the shared module, dropping its duplicate THEMES const, duplicate storage key, duplicate apply/persist logic, and the conflicting "dark" fallback. - Adds 11 unit tests in tests/theme.test.ts covering the default-fallback, known/unknown stored values, SecurityError-on-read, SSR (no localStorage), DOM-attribute write, persistence round-trip, and the "DOM still updates if storage write throws" contract. The daisyUI plugin block in app.css still lists themes — that's a CSS-time concern and can't be imported from TS, so it's kept in sync by convention (noted in the new module's doc comment).
Diffstat (limited to 'packages/frontend/tests')
-rw-r--r--packages/frontend/tests/theme.test.ts144
1 files changed, 144 insertions, 0 deletions
diff --git a/packages/frontend/tests/theme.test.ts b/packages/frontend/tests/theme.test.ts
new file mode 100644
index 0000000..63a343d
--- /dev/null
+++ b/packages/frontend/tests/theme.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Tests for `src/lib/theme.ts` — the shared theme picker module.
+ *
+ * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
+ * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
+ * defaults and could disagree. After consolidation, both call into
+ * this module and the bug class is gone — these tests pin that
+ * invariant.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";
+
+const LS_KEY = "dispatch-theme";
+
+function makeLocalStorageMock(): Storage {
+ const store = new Map<string, string>();
+ return {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ },
+ clear: () => {
+ store.clear();
+ },
+ get length() {
+ return store.size;
+ },
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
+ };
+}
+
+function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
+ const attrs = new Map<string, string>();
+ return {
+ documentElement: {
+ setAttribute: (k: string, v: string) => {
+ attrs.set(k, v);
+ },
+ // expose for assertions
+ // @ts-expect-error — test-only escape hatch
+ _attrs: attrs,
+ },
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", makeLocalStorageMock());
+ vi.stubGlobal("document", makeDocumentMock());
+});
+
+describe("DEFAULT_THEME", () => {
+ it("is one of the THEMES (sanity check that they can't drift)", () => {
+ expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
+ });
+});
+
+describe("loadStoredTheme", () => {
+ it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns the stored theme when it's a known theme", () => {
+ localStorage.setItem(LS_KEY, "dracula");
+ expect(loadStoredTheme()).toBe("dracula");
+ });
+
+ it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
+ // Guards against a stale storage entry from a removed theme,
+ // or a hand-edited bad value, falling through to daisyUI's own
+ // fallback (which is `light`, not our DEFAULT).
+ localStorage.setItem(LS_KEY, "solarized-rainbow");
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {},
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
+ vi.stubGlobal("localStorage", undefined);
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+});
+
+describe("applyTheme", () => {
+ it("writes data-theme on the document element", () => {
+ applyTheme("nord");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
+ });
+
+ it("persists the theme to localStorage", () => {
+ applyTheme("forest");
+ expect(localStorage.getItem(LS_KEY)).toBe("forest");
+ });
+
+ it("round-trips through loadStoredTheme", () => {
+ applyTheme("luxury");
+ expect(loadStoredTheme()).toBe("luxury");
+ });
+
+ it("does not throw when localStorage.setItem throws (quota etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(() => applyTheme("cyberpunk")).not.toThrow();
+ });
+
+ it("still writes to the DOM even if localStorage write throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ applyTheme("coffee");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
+ });
+});