Files
collective-lists/apps/web/src/lib/theme.ts
Oier Bravo Urtasun 5873c2fa9f feat(fase-9): ThemeToggle component + anti-FOUC bootstrap
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>
2026-05-18 01:09:19 +02:00

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');