Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142/** 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 compatibilityconst 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<ThemeMode>(storedMode());export const activeSchemeId = writable(storedScheme());export const customSchemes = writable<ThemeScheme[]>(storedCustomSchemes());export const installedSchemes = writable<ThemeScheme[]>([]);export const themeSchemes = derived([installedSchemes, customSchemes], ([$installed, $custom]) => [...BUILTIN_SCHEMES, ...$installed, ...$custom]);export const effectiveTheme = writable<ThemeVariant>("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<number> { 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);}