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>
This commit is contained in:
128
apps/web/src/lib/theme.test.ts
Normal file
128
apps/web/src/lib/theme.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Fase 9.1 — theme controller unit tests.
|
||||
*
|
||||
* `theme.ts` owns the source of truth for the user's UI theme. It exposes
|
||||
* a Svelte store + helpers that:
|
||||
* - persist the chosen preference (`light` | `dark` | `system`) to
|
||||
* localStorage so the anti-FOUC inline script in app.html reads it on
|
||||
* next load;
|
||||
* - reflect the *resolved* theme onto <html data-theme="..."> so Tailwind's
|
||||
* `dark:` selectors and the :root[data-theme=...] CSS tokens both fire;
|
||||
* - re-resolve when the user picks `system` and the OS preference flips.
|
||||
*
|
||||
* Test environment is jsdom (vitest config). We stub `matchMedia` per test.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
themePreference,
|
||||
resolvedTheme,
|
||||
setThemePreference,
|
||||
initTheme,
|
||||
type ThemePreference
|
||||
} from './theme';
|
||||
|
||||
function mockMatchMedia(prefersDark: boolean) {
|
||||
const listeners = new Set<(e: MediaQueryListEvent) => void>();
|
||||
const mql = {
|
||||
matches: prefersDark,
|
||||
media: '(prefers-color-scheme: dark)',
|
||||
addEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
||||
removeEventListener: (_: string, fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
|
||||
// Legacy APIs Svelte does not use but jsdom may probe
|
||||
addListener: (fn: (e: MediaQueryListEvent) => void) => listeners.add(fn),
|
||||
removeListener: (fn: (e: MediaQueryListEvent) => void) => listeners.delete(fn),
|
||||
dispatchEvent: (e: MediaQueryListEvent) => {
|
||||
listeners.forEach((l) => l(e));
|
||||
return true;
|
||||
},
|
||||
onchange: null
|
||||
};
|
||||
vi.stubGlobal('matchMedia', vi.fn().mockReturnValue(mql));
|
||||
(window as unknown as { matchMedia: typeof window.matchMedia }).matchMedia =
|
||||
globalThis.matchMedia;
|
||||
return mql;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
// Default the matchMedia mock to "system prefers light" unless a test
|
||||
// overrides it. Without this, jsdom would throw on the first read.
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('themePreference store + setThemePreference', () => {
|
||||
it('TH-01: defaults to "system" when nothing is stored', () => {
|
||||
initTheme();
|
||||
expect(get(themePreference)).toBe('system');
|
||||
});
|
||||
|
||||
it('TH-02: setting "light" writes localStorage and data-theme=light', () => {
|
||||
initTheme();
|
||||
setThemePreference('light');
|
||||
expect(localStorage.getItem('theme')).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
expect(get(themePreference)).toBe('light');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
});
|
||||
|
||||
it('TH-03: setting "dark" writes localStorage and data-theme=dark', () => {
|
||||
initTheme();
|
||||
setThemePreference('dark');
|
||||
expect(localStorage.getItem('theme')).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
expect(get(themePreference)).toBe('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-04: setting "system" resolves to dark when prefers-color-scheme: dark', () => {
|
||||
mockMatchMedia(true); // OS says dark
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(localStorage.getItem('theme')).toBe('system');
|
||||
expect(get(themePreference)).toBe('system');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-05: setting "system" resolves to light when prefers-color-scheme: light', () => {
|
||||
mockMatchMedia(false); // OS says light
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('light');
|
||||
});
|
||||
|
||||
it('TH-06: initTheme picks up an existing localStorage value on mount', () => {
|
||||
localStorage.setItem('theme', 'dark');
|
||||
initTheme();
|
||||
expect(get(themePreference)).toBe('dark');
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
|
||||
it('TH-07: rejects unknown preference values (falls back to "system")', () => {
|
||||
initTheme();
|
||||
// @ts-expect-error testing the runtime guard
|
||||
setThemePreference('sepia' as ThemePreference);
|
||||
expect(get(themePreference)).toBe('system');
|
||||
});
|
||||
|
||||
it('TH-08: "system" updates resolved theme when matchMedia fires "change"', () => {
|
||||
const mql = mockMatchMedia(false);
|
||||
initTheme();
|
||||
setThemePreference('system');
|
||||
expect(get(resolvedTheme)).toBe('light');
|
||||
|
||||
// OS switches to dark.
|
||||
mql.matches = true;
|
||||
mql.dispatchEvent(new Event('change') as MediaQueryListEvent);
|
||||
expect(get(resolvedTheme)).toBe('dark');
|
||||
expect(document.documentElement.getAttribute('data-theme')).toBe('dark');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user