/** Theme controller: mode resolution, palette selection, custom-scheme persistence and CSS application. */ import { derived, get, writable } from "svelte/store"; import { browser } from "$app/environment"; import { BUILTIN_SCHEMES, cloneScheme, toCssVariables, validateScheme, type ThemeMode, type ThemeScheme, type ThemeVariant, } from "./schemes"; export type ThemePref = ThemeMode; const MODE_KEY = "shard.web.theme"; // kept for backwards compatibility const SCHEME_KEY = "shard.web.theme.scheme"; const CUSTOM_KEY = "shard.web.theme.custom-schemes"; const APPLIED_KEY = "shard.web.theme.applied"; function storedMode(): ThemeMode { if (!browser) return "auto"; const value = localStorage.getItem(MODE_KEY); return value === "light" || value === "dark" || value === "auto" ? value : "auto"; } function storedScheme(): string { return browser ? (localStorage.getItem(SCHEME_KEY) || "shard") : "shard"; } function storedCustomSchemes(): ThemeScheme[] { if (!browser) return []; try { const value: unknown = JSON.parse(localStorage.getItem(CUSTOM_KEY) || "[]"); if (!Array.isArray(value)) return []; return value.flatMap((item) => { const result = validateScheme(item); return result.ok ? [result.scheme] : []; }); } catch { return []; } } export const themePref = writable(storedMode()); export const activeSchemeId = writable(storedScheme()); export const customSchemes = writable(storedCustomSchemes()); export const installedSchemes = writable([]); export const themeSchemes = derived([installedSchemes, customSchemes], ([$installed, $custom]) => [...BUILTIN_SCHEMES, ...$installed, ...$custom]); export const effectiveTheme = writable("light"); function resolveMode(mode: ThemeMode): ThemeVariant { if (mode !== "auto") return mode; return browser && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function schemeFor(id: string, custom = get(customSchemes)): ThemeScheme { return [...BUILTIN_SCHEMES, ...get(installedSchemes), ...custom].find((scheme) => scheme.id === id) ?? BUILTIN_SCHEMES[0]!; } function apply(): void { if (!browser) return; const variant = resolveMode(get(themePref)); const scheme = schemeFor(get(activeSchemeId)); const root = document.documentElement; root.dataset.theme = variant; root.dataset.themeScheme = scheme.id; root.style.colorScheme = variant; const variables = toCssVariables(scheme[variant]); for (const [name, value] of Object.entries(variables)) root.style.setProperty(name, value); localStorage.setItem(APPLIED_KEY, JSON.stringify({ variant, schemeId: scheme.id, variables })); effectiveTheme.set(variant); } function persistCustom(schemes: ThemeScheme[]): void { if (browser) localStorage.setItem(CUSTOM_KEY, JSON.stringify(schemes)); } if (browser) { apply(); themePref.subscribe((mode) => { localStorage.setItem(MODE_KEY, mode); apply(); }); activeSchemeId.subscribe((id) => { localStorage.setItem(SCHEME_KEY, id); apply(); }); customSchemes.subscribe((schemes) => { persistCustom(schemes); apply(); }); installedSchemes.subscribe(() => apply()); window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => { if (get(themePref) === "auto") apply(); }); } export function setThemeMode(mode: ThemeMode): void { themePref.set(mode); } export function selectThemeScheme(id: string): void { activeSchemeId.set(schemeFor(id).id); } export function cycleTheme(): ThemeMode { const current = get(themePref); const next: ThemeMode = current === "light" ? "dark" : current === "dark" ? "auto" : "light"; themePref.set(next); return next; } export function saveCustomScheme(input: ThemeScheme): ThemeScheme { const result = validateScheme(input); if (!result.ok) throw new Error(result.error); const scheme = { ...result.scheme, builtin: false }; customSchemes.update((items) => [...items.filter((item) => item.id !== scheme.id), scheme]); activeSchemeId.set(scheme.id); return scheme; } export function createCustomScheme(sourceId = get(activeSchemeId)): ThemeScheme { return cloneScheme(schemeFor(sourceId)); } export function removeCustomScheme(id: string): void { customSchemes.update((items) => items.filter((item) => item.id !== id)); if (get(activeSchemeId) === id) activeSchemeId.set("shard"); } export function exportThemeScheme(id = get(activeSchemeId)): string { const { builtin: _builtin, installed: _installed, ...scheme } = schemeFor(id); return JSON.stringify(scheme, null, 2); } export async function refreshInstalledSchemes(fetcher: typeof fetch = fetch): Promise { const response = await fetcher("/api/themes", { credentials: "include" }); if (!response.ok) throw new Error(`Unable to load installed themes (${response.status})`); const body = await response.json() as { themes?: unknown }; if (!Array.isArray(body.themes)) throw new Error("Installed-theme response is malformed"); const reserved = new Set(BUILTIN_SCHEMES.map((scheme) => scheme.id)); const loaded: ThemeScheme[] = []; for (const value of body.themes) { const result = validateScheme(value); if (!result.ok || reserved.has(result.scheme.id)) continue; reserved.add(result.scheme.id); loaded.push({ ...result.scheme, installed: true }); } installedSchemes.set(loaded); return loaded.length; } export function importThemeScheme(json: string): ThemeScheme { let parsed: unknown; try { parsed = JSON.parse(json); } catch { throw new Error("Theme file is not valid JSON"); } const result = validateScheme(parsed); if (!result.ok) throw new Error(result.error); const existingIds = new Set([...BUILTIN_SCHEMES, ...get(installedSchemes), ...get(customSchemes)].map((item) => item.id)); const scheme = existingIds.has(result.scheme.id) ? { ...result.scheme, id: `custom-${Date.now()}` } : result.scheme; return saveCustomScheme(scheme); }