Wires the dark-mode plumbing: - New $lib/theme module: themePreference + resolvedTheme stores plus initTheme() / setThemePreference(). Owns localStorage persistence, <html data-theme=...> mirroring, and the matchMedia listener that re-resolves "system" when the OS preference flips. Storage-agnostic (no Supabase coupling) so the caller persists to public.users.theme separately. - Inline anti-FOUC script in app.html reads localStorage + matchMedia synchronously and writes data-theme before the first paint — refresh in dark mode no longer flashes white. - Tailwind switched to darkMode: ['selector', '[data-theme="dark"]'] so all existing `dark:` utilities resolve from the same attribute. - :root and :root[data-theme=light] share the light token set; dark tokens move into :root[data-theme=dark]. Tokens stay as RGB triplets (rgb(var(--token) / <alpha>)) per the repo gotcha — no hsl(). - ThemeToggle.svelte: three-way radiogroup (Light / Dark / System) with paraglide messages in EN + ES. Tests (TDD): 8 new unit specs in src/lib/theme.test.ts cover defaults, localStorage round-trip, data-theme reflection, system resolution against matchMedia, invalid-value fallback, and OS preference change propagation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
120 lines
3.9 KiB
TypeScript
120 lines
3.9 KiB
TypeScript
/**
|
|
* 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 <html data-theme="..."> 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<ThemePreference>('system');
|
|
export const resolvedTheme = writable<ResolvedTheme>('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 <html data-theme>. 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');
|