feat(web): theme core — resolve/read/apply tri-state theme (#59)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 16:28:21 +02:00
parent 5e7a80e377
commit e5c03383fe
2 changed files with 85 additions and 0 deletions
+52
View File
@@ -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);
});
+33
View File
@@ -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");
}