import { broadcastThemeChange } from '$lib/utils/broadcastSync.js'; const THEME_STORAGE_KEY = 'wal-theme-mode'; const PRIMARY_HUE_KEY = 'wal-primary-hue'; const PRIMARY_SAT_KEY = 'wal-primary-sat'; const BG_TYPE_KEY = 'wal-bg-type'; const CARD_STYLE_KEY = 'wal-card-style'; export type ThemeMode = 'dark' | 'light' | 'system'; export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none' | 'wallpaper'; export type CardStyle = 'solid' | 'glass' | 'outline'; function getStoredValue(key: string, fallback: T): T { if (typeof window === 'undefined') return fallback; try { const stored = localStorage.getItem(key); if (stored === null) return fallback; return stored as unknown as T; } catch { return fallback; } } function getStoredNumber(key: string, fallback: number): number { if (typeof window === 'undefined') return fallback; try { const stored = localStorage.getItem(key); if (stored === null) return fallback; const parsed = Number(stored); return Number.isNaN(parsed) ? fallback : parsed; } catch { return fallback; } } class ThemeStore { mode = $state('system'); primaryHue = $state(220); primarySaturation = $state(70); backgroundType = $state('mesh'); cardStyle = $state('solid'); #systemPreference: 'dark' | 'light' = 'dark'; #suppressBroadcast = false; resolvedMode = $derived<'dark' | 'light'>( this.mode === 'system' ? this.#systemPreference : this.mode ); isDark = $derived(this.resolvedMode === 'dark'); constructor() { if (typeof window !== 'undefined') { this.mode = getStoredValue(THEME_STORAGE_KEY, 'system'); this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220); this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70); this.backgroundType = getStoredValue(BG_TYPE_KEY, 'mesh'); this.cardStyle = getStoredValue(CARD_STYLE_KEY, 'solid'); const mql = window.matchMedia('(prefers-color-scheme: dark)'); this.#systemPreference = mql.matches ? 'dark' : 'light'; mql.addEventListener('change', (e) => { this.#systemPreference = e.matches ? 'dark' : 'light'; }); } } /** Must be called from within a component to set up persistence and DOM effects */ initEffects() { $effect(() => { if (typeof window === 'undefined') return; localStorage.setItem(THEME_STORAGE_KEY, this.mode); }); $effect(() => { if (typeof window === 'undefined') return; localStorage.setItem(PRIMARY_HUE_KEY, String(this.primaryHue)); }); $effect(() => { if (typeof window === 'undefined') return; localStorage.setItem(PRIMARY_SAT_KEY, String(this.primarySaturation)); }); $effect(() => { if (typeof window === 'undefined') return; localStorage.setItem(BG_TYPE_KEY, this.backgroundType); }); $effect(() => { if (typeof window === 'undefined') return; localStorage.setItem(CARD_STYLE_KEY, this.cardStyle); }); $effect(() => { if (typeof document === 'undefined') return; const html = document.documentElement; if (this.resolvedMode === 'dark') { html.classList.add('dark'); html.classList.remove('light'); } else { html.classList.remove('dark'); html.classList.add('light'); } }); $effect(() => { if (typeof document === 'undefined') return; const html = document.documentElement; html.style.setProperty('--primary-h', String(this.primaryHue)); html.style.setProperty('--primary-s', `${this.primarySaturation}%`); }); // Broadcast theme changes to other tabs $effect(() => { // Read all reactive values to track them const snapshot = { mode: this.mode, primaryHue: this.primaryHue, primarySaturation: this.primarySaturation, backgroundType: this.backgroundType, cardStyle: this.cardStyle }; if (typeof window === 'undefined') return; if (this.#suppressBroadcast) return; broadcastThemeChange(snapshot); }); } cycleMode() { const modes: ThemeMode[] = ['light', 'dark', 'system']; const idx = modes.indexOf(this.mode); this.mode = modes[(idx + 1) % modes.length]; } setMode(mode: ThemeMode) { this.mode = mode; } setBackground(bg: BackgroundType) { this.backgroundType = bg; } setCardStyle(style: CardStyle) { this.cardStyle = style; } setPrimaryColor(hue: number, saturation: number) { this.primaryHue = Math.max(0, Math.min(360, hue)); this.primarySaturation = Math.max(0, Math.min(100, saturation)); } /** * Apply theme values received from another tab via BroadcastChannel. * Suppresses re-broadcasting to avoid echo loops. */ applyFromBroadcast(values: { mode: ThemeMode; primaryHue: number; primarySaturation: number; backgroundType: BackgroundType; cardStyle?: CardStyle; }) { this.#suppressBroadcast = true; this.mode = values.mode; this.primaryHue = values.primaryHue; this.primarySaturation = values.primarySaturation; this.backgroundType = values.backgroundType; if (values.cardStyle) this.cardStyle = values.cardStyle; // Use setTimeout to ensure all Svelte 5 effects have fired before re-enabling broadcast setTimeout(() => { this.#suppressBroadcast = false; }, 100); } /** * Apply non-null server-stored user preferences over localStorage defaults. * Call from +layout.svelte when user data is available. */ loadFromServer(prefs: { themeMode?: string | null; primaryHue?: number | null; primarySaturation?: number | null; backgroundType?: string | null; cardStyle?: string | null; }) { if (prefs.themeMode != null) { this.mode = prefs.themeMode as ThemeMode; } if (prefs.primaryHue != null) { this.primaryHue = prefs.primaryHue; } if (prefs.primarySaturation != null) { this.primarySaturation = prefs.primarySaturation; } if (prefs.backgroundType != null) { this.backgroundType = prefs.backgroundType as BackgroundType; } if (prefs.cardStyle != null) { this.cardStyle = prefs.cardStyle as CardStyle; } } } export const theme = new ThemeStore();