import { getContext, setContext } from 'svelte'; import { browser } from '$app/environment'; import { mode } from 'mode-watcher'; import { DEFAULT_PRESET_ID, THEME_COOKIE, getPreset, resolveFonts, resolvePalette, resolveRadius, type ColorTokenKey, type FontTokenKey, type Mode, type ThemeConfig } from './presets'; /** * Live theme state. * * The server has already inlined the stored theme into the document head, so * this class only takes over once the user edits something: it writes the * changed custom properties onto and persists the config to a cookie. */ export class ThemeState { config = $state({ preset: DEFAULT_PRESET_ID }); /** Which palette the editor is currently writing to. */ readonly mode = $derived(mode.current === 'dark' ? 'dark' : 'light'); readonly preset = $derived(getPreset(this.config.preset)); readonly palette = $derived(resolvePalette(this.config, this.mode)); readonly radius = $derived(resolveRadius(this.config)); readonly fonts = $derived(resolveFonts(this.config)); readonly isCustomised = $derived( Boolean( this.config.radius || this.config.fonts || Object.keys(this.config.light ?? {}).length || Object.keys(this.config.dark ?? {}).length ) ); /** * True once this session has written inline custom properties onto . * Until then the server-injected stylesheet is the only source of theme, and * it already covers both modes — so we touch nothing and there is no repaint. */ private touched = false; constructor(config: ThemeConfig) { this.config = config; } setPreset(id: string) { // Switching preset discards overrides; keeping them across palettes // produces colour combinations nobody chose. this.config = { preset: id }; this.apply(); } setColor(token: ColorTokenKey | string, value: string) { const mode = this.mode; this.config = { ...this.config, [mode]: { ...(this.config[mode] ?? {}), [token]: value } }; this.apply(); } setRadius(value: string) { this.config = { ...this.config, radius: value }; this.apply(); } setFont(token: FontTokenKey, value: string) { this.config = { ...this.config, fonts: { ...(this.config.fonts ?? {}), [token]: value } }; this.apply(); } reset() { this.config = { preset: this.config.preset }; this.apply(); } /** Push the resolved theme onto the document and persist it. */ apply() { if (!browser) return; this.touched = true; const root = document.documentElement; root.style.setProperty('--radius', this.radius); for (const [key, value] of Object.entries(this.fonts)) { root.style.setProperty(`--${key}`, value); } // Inline styles sit on :root and so apply in both modes. Write the // palette for the mode being displayed and rewrite it on mode change. for (const [key, value] of Object.entries(this.palette)) { root.style.setProperty(`--${key}`, value); } this.persist(); } /** * Re-apply after a light/dark switch, since inline vars are mode-blind. * No-op until something has actually been edited — see `touched`. */ syncMode() { if (!browser || !this.touched) return; const palette = resolvePalette(this.config, this.mode); for (const [key, value] of Object.entries(palette)) { document.documentElement.style.setProperty(`--${key}`, value); } } private persist() { if (!browser) return; const value = encodeURIComponent(JSON.stringify(this.config)); // One year, root path, lax — same shape as the auth cookie minus httpOnly, // because the editor has to be able to read and rewrite it client-side. document.cookie = `${THEME_COOKIE}=${value};path=/;max-age=31536000;samesite=lax`; } } const THEME_KEY = Symbol('THEME'); export function setThemeState(config: ThemeConfig) { return setContext(THEME_KEY, new ThemeState(config)); } export function getThemeState() { return getContext>(THEME_KEY); }