/** * Fase 9.1 — theme controller. * * Source of truth for the user's UI theme. Three preference values: * - 'light' → force light mode * - 'dark' → force dark mode * - 'system' → follow OS prefers-color-scheme (default for new users) * * Two stores: * - `themePreference` — the raw user choice (one of the three above). * - `resolvedTheme` — what's actually painted: 'light' | 'dark'. * * Side effects (all browser-side, guarded for SSR): * - writes `localStorage.theme` so app.html's inline anti-FOUC script * can read it synchronously on the next load; * - mirrors `resolvedTheme` onto so Tailwind's * `dark:` utilities + the :root[data-theme=...] CSS tokens resolve. * * Persistence to public.users.theme is the caller's responsibility — see * `ThemeToggle.svelte` which fires the UPDATE in the background. We keep * the database write out of this module so unit tests don't need a * Supabase client. */ import { writable, derived, get } from 'svelte/store'; export type ThemePreference = 'light' | 'dark' | 'system'; export type ResolvedTheme = 'light' | 'dark'; const STORAGE_KEY = 'theme'; const VALID: readonly ThemePreference[] = ['light', 'dark', 'system'] as const; function isValid(value: string | null | undefined): value is ThemePreference { return value === 'light' || value === 'dark' || value === 'system'; } export const themePreference = writable('system'); export const resolvedTheme = writable('light'); let mql: MediaQueryList | null = null; let mqlListener: ((e: MediaQueryListEvent) => void) | null = null; let initialized = false; function detachMqlListener() { if (mql && mqlListener) { mql.removeEventListener('change', mqlListener); } mql = null; mqlListener = null; } function attachMqlListener() { if (typeof window === 'undefined' || !window.matchMedia) return; detachMqlListener(); mql = window.matchMedia('(prefers-color-scheme: dark)'); mqlListener = () => { // Only react if the user is currently on "system". if (get(themePreference) === 'system') { applyResolved(); } }; mql.addEventListener('change', mqlListener); } function osPrefersDark(): boolean { if (typeof window === 'undefined' || !window.matchMedia) return false; try { return window.matchMedia('(prefers-color-scheme: dark)').matches; } catch { return false; } } function resolve(pref: ThemePreference): ResolvedTheme { if (pref === 'system') return osPrefersDark() ? 'dark' : 'light'; return pref; } function applyResolved() { const r = resolve(get(themePreference)); resolvedTheme.set(r); if (typeof document !== 'undefined') { document.documentElement.setAttribute('data-theme', r); } } /** * Initialise the stores from localStorage and start listening for OS * preference changes. Safe to call more than once — re-runs only refresh * the listener subscription, never duplicate it. */ export function initTheme(): void { const stored = typeof localStorage !== 'undefined' ? localStorage.getItem(STORAGE_KEY) : null; const pref: ThemePreference = isValid(stored) ? stored : 'system'; themePreference.set(pref); applyResolved(); attachMqlListener(); initialized = true; } /** * Update the user's preference. Persists to localStorage, refreshes the * resolved theme, and updates . Invalid input falls back * to 'system' so we never store junk. */ export function setThemePreference(value: ThemePreference): void { if (!initialized) initTheme(); const next: ThemePreference = VALID.includes(value) ? value : 'system'; themePreference.set(next); if (typeof localStorage !== 'undefined') { localStorage.setItem(STORAGE_KEY, next); } applyResolved(); } /** * Derived store useful for components that only care about the live * resolved value — re-exported so callers can `$isDark`-style. */ export const isDark = derived(resolvedTheme, (r) => r === 'dark');