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 */