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:
@@ -183,5 +183,9 @@
|
|||||||
"list_bulk_mark_checked": "Mark checked",
|
"list_bulk_mark_checked": "Mark checked",
|
||||||
"list_undo_bulk_delete": "Deleted {n} items",
|
"list_undo_bulk_delete": "Deleted {n} items",
|
||||||
"list_move_title": "Move to list",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,5 +183,9 @@
|
|||||||
"list_bulk_mark_checked": "Marcar",
|
"list_bulk_mark_checked": "Marcar",
|
||||||
"list_undo_bulk_delete": "{n} eliminados",
|
"list_undo_bulk_delete": "{n} eliminados",
|
||||||
"list_move_title": "Mover a lista",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,22 @@
|
|||||||
/* Inter font */
|
/* Inter font */
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@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):
|
* Surface hierarchy (stacked sheets of paper):
|
||||||
* background → surface-container-low → surface → surface-raised (cards pop up)
|
* 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 */
|
--background: 247 249 251; /* #f7f9fb — spec exact value */
|
||||||
--surface-container-low: 241 245 249; /* slate-100 — subtle grouping bg */
|
--surface-container-low: 241 245 249; /* slate-100 — subtle grouping bg */
|
||||||
--surface: 255 255 255; /* white — base card surface */
|
--surface: 255 255 255; /* white — base card surface */
|
||||||
@@ -20,8 +31,11 @@
|
|||||||
--destructive: 185 28 28; /* red-700 ≈ #ba1a1a spec */
|
--destructive: 185 28 28; /* red-700 ≈ #ba1a1a spec */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Design tokens — dark mode */
|
/* Design tokens — dark mode.
|
||||||
.dark {
|
* 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 */
|
--background: 2 6 23; /* #020617 slate-950 — spec exact value */
|
||||||
--surface-container-low: 15 23 42; /* slate-900 — subtle grouping */
|
--surface-container-low: 15 23 42; /* slate-900 — subtle grouping */
|
||||||
--surface: 30 41 59; /* slate-800 — base card surface */
|
--surface: 30 41 59; /* slate-800 — base card surface */
|
||||||
|
|||||||
@@ -10,6 +10,32 @@
|
|||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Colectivo" />
|
<meta name="apple-mobile-web-app-title" content="Colectivo" />
|
||||||
<link rel="apple-touch-icon" href="/icons/icon-180.png" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
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>
|
||||||
128
apps/web/src/lib/theme.test.ts
Normal file
128
apps/web/src/lib/theme.test.ts
Normal 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
119
apps/web/src/lib/theme.ts
Normal 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');
|
||||||
@@ -2,7 +2,10 @@ import type { Config } from 'tailwindcss';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||||
darkMode: 'class',
|
// Fase 9.1: drive dark mode from <html data-theme="dark"> 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: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
|
|||||||
Reference in New Issue
Block a user