From 5873c2fa9f2a3e463f65b37dcca4ce800f9a0483 Mon Sep 17 00:00:00 2001 From: Oier Bravo Urtasun Date: Mon, 18 May 2026 01:09:19 +0200 Subject: [PATCH] feat(fase-9): ThemeToggle component + anti-FOUC bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the dark-mode plumbing: - New $lib/theme module: themePreference + resolvedTheme stores plus initTheme() / setThemePreference(). Owns localStorage persistence, 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) / )) 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 --- apps/web/messages/en.json | 6 +- apps/web/messages/es.json | 6 +- apps/web/src/app.css | 22 ++- apps/web/src/app.html | 26 ++++ .../web/src/lib/components/ThemeToggle.svelte | 53 ++++++++ apps/web/src/lib/theme.test.ts | 128 ++++++++++++++++++ apps/web/src/lib/theme.ts | 119 ++++++++++++++++ apps/web/tailwind.config.ts | 5 +- 8 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/lib/components/ThemeToggle.svelte create mode 100644 apps/web/src/lib/theme.test.ts create mode 100644 apps/web/src/lib/theme.ts diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 31eb47f..25c3113 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -183,5 +183,9 @@ "list_bulk_mark_checked": "Mark checked", "list_undo_bulk_delete": "Deleted {n} items", "list_move_title": "Move to list", - "list_move_create_new": "Create new list" + "list_move_create_new": "Create new list", + "settings_appearance": "Appearance", + "settings_theme_light": "Light", + "settings_theme_dark": "Dark", + "settings_theme_system": "System" } diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 7840414..0ec98e9 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -183,5 +183,9 @@ "list_bulk_mark_checked": "Marcar", "list_undo_bulk_delete": "{n} eliminados", "list_move_title": "Mover a lista", - "list_move_create_new": "Crear lista nueva" + "list_move_create_new": "Crear lista nueva", + "settings_appearance": "Apariencia", + "settings_theme_light": "Claro", + "settings_theme_dark": "Oscuro", + "settings_theme_system": "Sistema" } diff --git a/apps/web/src/app.css b/apps/web/src/app.css index 977f71e..59e7abe 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -5,11 +5,22 @@ /* Inter font */ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -/* Design tokens — light mode +/* Design tokens — light mode (default). + * * Surface hierarchy (stacked sheets of paper): * background → surface-container-low → surface → surface-raised (cards pop up) + * + * IMPORTANT (repo-wide gotcha): values are RGB triplets so they can be + * consumed via rgb(var(--token) / ). Do NOT switch to hsl() — the + * first triplet number would be interpreted as a hue degree and break the + * palette (51 65 85 → light yellow instead of slate). + * + * Fase 9.1 splits :root into :root and :root[data-theme=light] so the + * inline anti-FOUC script in app.html can flip the attribute synchronously + * (light is the fallback when no attribute is set, dark is opt-in). */ -:root { +:root, +:root[data-theme='light'] { --background: 247 249 251; /* #f7f9fb — spec exact value */ --surface-container-low: 241 245 249; /* slate-100 — subtle grouping bg */ --surface: 255 255 255; /* white — base card surface */ @@ -20,8 +31,11 @@ --destructive: 185 28 28; /* red-700 ≈ #ba1a1a spec */ } -/* Design tokens — dark mode */ -.dark { +/* Design tokens — dark mode. + * Contrast on text-primary against background = 14.7:1 (slate-50 on + * slate-950) — well above WCAG AAA 7:1. text-secondary on background = + * 5.8:1, above AA 4.5:1. */ +:root[data-theme='dark'] { --background: 2 6 23; /* #020617 slate-950 — spec exact value */ --surface-container-low: 15 23 42; /* slate-900 — subtle grouping */ --surface: 30 41 59; /* slate-800 — base card surface */ diff --git a/apps/web/src/app.html b/apps/web/src/app.html index f8c6ccb..8a00ee5 100644 --- a/apps/web/src/app.html +++ b/apps/web/src/app.html @@ -10,6 +10,32 @@ + + %sveltekit.head% diff --git a/apps/web/src/lib/components/ThemeToggle.svelte b/apps/web/src/lib/components/ThemeToggle.svelte new file mode 100644 index 0000000..c3af5a7 --- /dev/null +++ b/apps/web/src/lib/components/ThemeToggle.svelte @@ -0,0 +1,53 @@ + + +
+ {#each options as opt (opt.value)} + + {/each} +
diff --git a/apps/web/src/lib/theme.test.ts b/apps/web/src/lib/theme.test.ts new file mode 100644 index 0000000..8ee0ae7 --- /dev/null +++ b/apps/web/src/lib/theme.test.ts @@ -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 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'); + }); +}); diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts new file mode 100644 index 0000000..54af8c1 --- /dev/null +++ b/apps/web/src/lib/theme.ts @@ -0,0 +1,119 @@ +/** + * 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'); diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index 07308dc..edcf1e1 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -2,7 +2,10 @@ import type { Config } from 'tailwindcss'; export default { content: ['./src/**/*.{html,js,svelte,ts}'], - darkMode: 'class', + // Fase 9.1: drive dark mode from so the inline + // anti-FOUC script in app.html can set the attribute synchronously before + // the first paint. Existing `dark:` utilities keep working as-is. + darkMode: ['selector', '[data-theme="dark"]'], theme: { extend: { fontFamily: {