From e5c03383fe901d8445ca054e4e49183b45de9f78 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Sun, 7 Jun 2026 16:28:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20theme=20core=20=E2=80=94=20resolve?= =?UTF-8?q?/read/apply=20tri-state=20theme=20(#59)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- web/src/theme/theme.test.ts | 52 +++++++++++++++++++++++++++++++++++++ web/src/theme/theme.ts | 33 +++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 web/src/theme/theme.test.ts create mode 100644 web/src/theme/theme.ts diff --git a/web/src/theme/theme.test.ts b/web/src/theme/theme.test.ts new file mode 100644 index 0000000..92c4d33 --- /dev/null +++ b/web/src/theme/theme.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, test, vi } from "vitest"; +import { applyTheme, readTheme, resolveTheme, THEME_KEY } from "./theme"; + +function mockMatchMedia(matches: boolean) { + vi.stubGlobal("matchMedia", (query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })); +} + +afterEach(() => { + vi.unstubAllGlobals(); + localStorage.clear(); + document.documentElement.classList.remove("dark"); +}); + +test("resolveTheme returns explicit values verbatim", () => { + expect(resolveTheme("light")).toBe("light"); + expect(resolveTheme("dark")).toBe("dark"); +}); + +test("resolveTheme maps system via prefers-color-scheme", () => { + mockMatchMedia(true); + expect(resolveTheme("system")).toBe("dark"); + mockMatchMedia(false); + expect(resolveTheme("system")).toBe("light"); +}); + +test("readTheme defaults to system when unset or invalid", () => { + expect(readTheme()).toBe("system"); + localStorage.setItem(THEME_KEY, "bogus"); + expect(readTheme()).toBe("system"); + localStorage.setItem(THEME_KEY, "dark"); + expect(readTheme()).toBe("dark"); +}); + +test("applyTheme toggles the dark class on documentElement", () => { + mockMatchMedia(false); + applyTheme("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + applyTheme("light"); + expect(document.documentElement.classList.contains("dark")).toBe(false); + mockMatchMedia(true); + applyTheme("system"); + expect(document.documentElement.classList.contains("dark")).toBe(true); +}); diff --git a/web/src/theme/theme.ts b/web/src/theme/theme.ts new file mode 100644 index 0000000..3869139 --- /dev/null +++ b/web/src/theme/theme.ts @@ -0,0 +1,33 @@ +export const THEME_KEY = "theme"; + +export type Theme = "light" | "dark" | "system"; + +const THEMES: readonly Theme[] = ["light", "dark", "system"]; + +function prefersDark(): boolean { + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +export function resolveTheme(theme: Theme): "light" | "dark" { + if (theme === "light" || theme === "dark") return theme; + + return prefersDark() ? "dark" : "light"; +} + +export function readTheme(): Theme { + if (typeof localStorage === "undefined") return "system"; + + const stored = localStorage.getItem(THEME_KEY); + + return THEMES.includes(stored as Theme) ? (stored as Theme) : "system"; +} + +export function applyTheme(theme: Theme): void { + if (typeof document === "undefined") return; + + document.documentElement.classList.toggle("dark", resolveTheme(theme) === "dark"); +}