// Theme system — built-in + user-made custom themes. // // A theme = a base (dark/light) plus an overlay of CSS-variable values applied as // inline styles on , which win over the :root / body.light stylesheet vars. // Everything is CSS-variable driven, so a theme just changes token values. export type ThemeBase = "dark" | "light"; export interface Theme { id: string; name: string; base: ThemeBase; builtin?: boolean; vars: Record; // e.g. { "--accent": "#7c6ef6" } } // The tokens a user can edit. Grouped for the editor UI. Every one is a color. export const THEME_TOKENS: { var: string; label: string; group: string }[] = [ { var: "--accent", label: "Accent", group: "Brand" }, { var: "--accent-2", label: "Accent light", group: "Brand" }, { var: "--accent-deep", label: "Accent deep", group: "Brand" }, { var: "--bg", label: "Background", group: "Surfaces" }, { var: "--panel", label: "Panel", group: "Surfaces" }, { var: "--panel-2", label: "Panel 2", group: "Surfaces" }, { var: "--panel-3", label: "Panel 3", group: "Surfaces" }, { var: "--elevated", label: "Elevated", group: "Surfaces" }, { var: "--border", label: "Border", group: "Surfaces" }, { var: "--txt", label: "Text", group: "Text" }, { var: "--muted", label: "Muted text", group: "Text" }, { var: "--faint", label: "Faint text", group: "Text" }, { var: "--add", label: "Add / success", group: "Status" }, { var: "--edit", label: "Edit / warning", group: "Status" }, { var: "--del", label: "Delete / danger", group: "Status" }, ]; export const TOKEN_VARS = THEME_TOKENS.map((t) => t.var); // Built-in presets. "dark"/"light" carry no overlay (pure stylesheet base); the // rest recolor a few tokens on top of a base to give a distinct look. export const BUILTIN_THEMES: Theme[] = [ { id: "dark", name: "Midnight (default)", base: "dark", builtin: true, vars: {} }, { id: "light", name: "Daylight", base: "light", builtin: true, vars: {} }, { id: "ember", name: "Ember", base: "dark", builtin: true, vars: { "--accent": "#f0883e", "--accent-2": "#f7a75f", "--accent-deep": "#c96a22", "--bg": "#0f0b07", "--panel": "#1a130c", "--panel-2": "#20180f", "--panel-3": "#281d12" } }, { id: "forest", name: "Forest", base: "dark", builtin: true, vars: { "--accent": "#35c88a", "--accent-2": "#5fe0a5", "--accent-deep": "#22a06e", "--bg": "#07100b", "--panel": "#0e1a13", "--panel-2": "#12211a", "--panel-3": "#182a20" } }, { id: "ocean", name: "Ocean", base: "dark", builtin: true, vars: { "--accent": "#3aa0ff", "--accent-2": "#6cbaff", "--accent-deep": "#1f7ce0", "--bg": "#060b12", "--panel": "#0c141f", "--panel-2": "#101a28", "--panel-3": "#152234" } }, { id: "rose", name: "Rosé (light)", base: "light", builtin: true, vars: { "--accent": "#e0426a", "--accent-2": "#f06489", "--accent-deep": "#c02b52", "--bg": "#faf3f5" } }, ]; const CUSTOM_KEY = "exd-themes-custom"; const ACTIVE_KEY = "exd-theme-id"; const LEGACY_KEY = "exd-theme"; // old "light"/"dark" flag export function loadCustomThemes(): Theme[] { try { const raw = localStorage.getItem(CUSTOM_KEY); if (!raw) return []; const arr = JSON.parse(raw); return Array.isArray(arr) ? (arr as Theme[]).filter((t) => t && t.id && t.vars) : []; } catch { return []; } } export function saveCustomThemes(list: Theme[]) { try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch {} } export function allThemes(): Theme[] { return [...BUILTIN_THEMES, ...loadCustomThemes()]; } export function loadActiveId(): string { try { const id = localStorage.getItem(ACTIVE_KEY); if (id) return id; // migrate from the old dark/light flag return localStorage.getItem(LEGACY_KEY) === "light" ? "light" : "dark"; } catch { return "dark"; } } export function saveActiveId(id: string) { try { localStorage.setItem(ACTIVE_KEY, id); const th = allThemes().find((t) => t.id === id); localStorage.setItem(LEGACY_KEY, th?.base === "light" ? "light" : "dark"); // keep legacy in sync } catch {} } export function resolveTheme(id: string): Theme { return allThemes().find((t) => t.id === id) || BUILTIN_THEMES[0]!; } // Apply a theme to the document: set the base class, then overlay its var values // (clearing any previous overlay first). export function applyTheme(theme: Theme) { const body = document.body; for (const v of TOKEN_VARS) body.style.removeProperty(v); body.classList.toggle("light", theme.base === "light"); for (const [k, val] of Object.entries(theme.vars)) { if (val) body.style.setProperty(k, val); } } // Read the effective value of a token right now (for seeding the editor). Falls // back to the theme's overlay or the computed stylesheet value. export function tokenValue(theme: Theme, varName: string): string { if (theme.vars[varName]) return theme.vars[varName]!; const el = document.createElement("div"); el.style.color = `var(${varName})`; // temporarily set base so computed value reflects the right stylesheet const hadLight = document.body.classList.contains("light"); document.body.classList.toggle("light", theme.base === "light"); document.body.appendChild(el); const rgb = getComputedStyle(el).color; el.remove(); document.body.classList.toggle("light", hadLight); return rgbToHex(rgb) || "#7c6ef6"; } function rgbToHex(rgb: string): string | null { const m = rgb.match(/rgba?\(([^)]+)\)/); if (!m || !m[1]) return null; const parts = m[1].split(",").map((x) => parseInt(x.trim(), 10)); const [r, g, b] = parts; if (r == null || g == null || b == null) return null; const h = (n: number) => n.toString(16).padStart(2, "0"); return `#${h(r)}${h(g)}${h(b)}`; } export function newCustomId(): string { // no Date.now/Math.random dependency worries in the app runtime; keep it simple return "custom-" + Math.random().toString(36).slice(2, 9); } export function exportTheme(theme: Theme): string { return JSON.stringify({ name: theme.name, base: theme.base, vars: theme.vars }, null, 2); } export function importTheme(json: string): Theme { const o = JSON.parse(json); if (!o || typeof o !== "object" || !o.vars) throw new Error("Invalid theme file"); return { id: newCustomId(), name: String(o.name || "Imported theme"), base: o.base === "light" ? "light" : "dark", vars: o.vars }; }