/** * @factory/ui — theme resolution + application (PURE, DOM-duck-typed, testable). * * `Theme` = explicit "light" | "dark" or "system" (follow OS). `applyThemeTo` * accepts a minimal duck-typed document so the whole flow is unit-testable * without a real DOM; `matchMedia` absence is guarded (SSR / older runtimes). */ export type Theme = "light" | "dark" | "system"; export type ResolvedTheme = "light" | "dark"; /** localStorage key for the persisted user preference. */ export function themeLocalStorageKey(): string { return "factory.theme"; } /** Media query describing the OS "dark" preference. */ export function systemDarkQuery(): string { return "(prefers-color-scheme: dark)"; } /** Resolve a stored preference against the current OS setting. */ export function resolveTheme(pref: Theme, systemDark: boolean): ResolvedTheme { if (pref === "light") return "light"; if (pref === "dark") return "dark"; return systemDark ? "dark" : "light"; } /** Minimal DOM document surface needed by {@link applyThemeTo}. */ export interface ThemeDocument { dataset: Record; addEventListener?: (type: string, handler: (e: unknown) => void) => void; removeEventListener?: (type: string, handler: (e: unknown) => void) => void; } interface MediaQueryLike { matches: boolean; addEventListener?: (type: string, handler: () => void) => void; removeEventListener?: (type: string, handler: () => void) => void; /** Safari <14 style. */ addListener?: (handler: () => void) => void; removeListener?: (handler: () => void) => void; } /** * Active system-preference listener per media object. A module-level WeakMap * makes `applyThemeTo` idempotent: re-applying "system" replaces (never * stacks) the previous listener, and explicit prefs detach it. */ const SYSTEM_LISTENERS = new WeakMap void>(); function detachSystemListener(media: MediaQueryLike | null): void { if (!media) return; const handler = SYSTEM_LISTENERS.get(media); if (!handler) return; media.removeEventListener?.("change", handler); media.removeListener?.(handler); SYSTEM_LISTENERS.delete(media); } /** * Apply a theme preference to the document root. * * 1. Sets `data-theme="light|dark"` on `document.dataset` (i.e. ``). * 2. If pref is "system", (re)binds a `change` listener on * `matchMedia("(prefers-color-scheme: dark)")` so OS switches are live; * any previously bound listener is unregistered first (idempotent). * 3. Non-system prefs remove any system listener. * * Returns the resolved theme actually applied. */ export function applyThemeTo( document: ThemeDocument, pref: Theme, windowLike?: unknown, ): ResolvedTheme { const media = typeof (windowLike as { matchMedia?: (q: string) => MediaQueryLike } | undefined) ?.matchMedia === "function" ? (windowLike as { matchMedia: (q: string) => MediaQueryLike }).matchMedia(systemDarkQuery()) : null; // guard: no matchMedia (SSR / older runtimes) const resolved = resolveTheme(pref, media ? media.matches : false); document.dataset.theme = resolved; if (pref === "system" && media) { detachSystemListener(media); // idempotent: replace any previous system listener const handler = (): void => { document.dataset.theme = resolveTheme(pref, media.matches); }; // Register on exactly ONE API (modern first, legacy Safari fallback) so the // listener is counted once per media object. if (media.addEventListener) { media.addEventListener("change", handler); } else if (media.addListener) { media.addListener(handler); } SYSTEM_LISTENERS.set(media, handler); } else { detachSystemListener(media); } return resolved; }