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:
53
apps/web/src/lib/components/ThemeToggle.svelte
Normal file
53
apps/web/src/lib/components/ThemeToggle.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user