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

@@ -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>