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:
2026-05-18 01:09:19 +02:00
parent 3efe0b9be0
commit 5873c2fa9f
8 changed files with 358 additions and 7 deletions

View File

@@ -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) / <alpha>). 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 */

View File

@@ -10,6 +10,32 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Colectivo" />
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
<!--
Fase 9.1: anti-FOUC theme bootstrapping.
Runs synchronously before the first paint so the page never
renders a light frame and then flashes dark on refresh.
localStorage.theme is one of 'light' | 'dark' | 'system' (default
'system'). For 'system' we read prefers-color-scheme. The result is
written to <html data-theme="..."> — Tailwind's `dark:` utilities
and the :root[data-theme=...] tokens both resolve from this attr.
-->
<script>
(function () {
try {
var stored = localStorage.getItem('theme');
var pref = stored === 'light' || stored === 'dark' ? stored : 'system';
var resolved =
pref === 'system'
? (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light')
: pref;
document.documentElement.setAttribute('data-theme', resolved);
} catch (_) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@@ -0,0 +1,53 @@
<script lang="ts">
/**
* Fase 9.1 — three-way segmented control: Light / Dark / System.
*
* The component is purely presentational state-wise: it reads the
* authoritative `themePreference` store from `$lib/theme` and writes
* back via `setThemePreference`. That single function takes care of
* persisting to localStorage and flipping <html data-theme>.
*
* The optional `onChange` prop lets the parent persist the choice to
* the user's row in `public.users.theme`. We keep the DB write outside
* the toggle so the component stays storage-agnostic (and easy to test).
*/
import { themePreference, setThemePreference, type ThemePreference } from '$lib/theme';
import * as m from '$lib/paraglide/messages';
let { onChange }: { onChange?: (next: ThemePreference) => void } = $props();
const options: Array<{ value: ThemePreference; label: () => string }> = [
{ value: 'light', label: () => m.settings_theme_light() },
{ value: 'dark', label: () => m.settings_theme_dark() },
{ value: 'system', label: () => m.settings_theme_system() }
];
function select(value: ThemePreference) {
if ($themePreference === value) return;
setThemePreference(value);
onChange?.(value);
}
</script>
<div
role="radiogroup"
aria-label={m.settings_appearance()}
class="inline-flex items-center gap-1 rounded-md border border-slate-300 bg-surface p-1 dark:border-slate-600 dark:bg-slate-800"
data-testid="theme-toggle"
>
{#each options as opt (opt.value)}
<button
type="button"
role="radio"
aria-checked={$themePreference === opt.value}
data-theme-option={opt.value}
onclick={() => select(opt.value)}
class="rounded px-3 py-1.5 text-xs font-medium transition-colors
{$themePreference === opt.value
? 'bg-slate-900 text-white dark:bg-slate-50 dark:text-slate-900'
: 'text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700'}"
>
{opt.label()}
</button>
{/each}
</div>

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

119
apps/web/src/lib/theme.ts Normal file
View File

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