// A theme file is a CSS file with one `:root { --c-*: ...; }` block defining every variable in `THEME_CSS_VARS`; `useSettingsPanel` applies the selected one as an unscoped stylesheet only while no built-in `html.` class is present, so it wins the cascade without rewriting the selector. /** Canonical list of every `--c-*` variable every theme in `index.css` defines. */ export const THEME_CSS_VARS = [ '--c-surface-0', '--c-surface-1', '--c-surface-2', '--c-border', '--c-border-soft', '--c-border-faint', '--c-border-strong', '--c-text', '--c-text-secondary', '--c-text-muted', '--c-text-subtle', '--c-text-faint', '--c-text-faintest', '--c-danger-bg', '--c-danger-border', '--c-danger-text', '--c-danger-text-strong', '--c-accent', '--c-accent-soft', '--c-accent-text', ] as const export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string> // Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two. export const BUILTIN_THEMES: Record = { dark: { '--c-surface-0': '#0a0a0a', '--c-surface-1': '#171717', '--c-surface-2': '#262626', '--c-border': '#262626', '--c-border-soft': '#404040', '--c-border-faint': '#171717', '--c-border-strong': '#a3a3a3', '--c-text': '#f5f5f5', '--c-text-secondary': '#d4d4d4', '--c-text-muted': '#a3a3a3', '--c-text-subtle': '#737373', '--c-text-faint': '#525252', '--c-text-faintest': '#404040', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#0ea5e9', '--c-accent-soft': '#0369a1', '--c-accent-text': '#7dd3fc', }, light: { '--c-surface-0': '#fafafa', '--c-surface-1': '#f5f5f5', '--c-surface-2': '#e5e5e5', '--c-border': '#e5e5e5', '--c-border-soft': '#d4d4d4', '--c-border-faint': '#f5f5f5', '--c-border-strong': '#525252', '--c-text': '#171717', '--c-text-secondary': '#404040', '--c-text-muted': '#525252', '--c-text-subtle': '#737373', '--c-text-faint': '#a3a3a3', '--c-text-faintest': '#d4d4d4', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': '#0284c7', '--c-accent-soft': '#7dd3fc', '--c-accent-text': '#0369a1', }, grey: { '--c-surface-0': '#0a0a0a', '--c-surface-1': '#171717', '--c-surface-2': '#262626', '--c-border': '#262626', '--c-border-soft': '#404040', '--c-border-faint': '#171717', '--c-border-strong': '#a3a3a3', '--c-text': '#f5f5f5', '--c-text-secondary': '#d4d4d4', '--c-text-muted': '#a3a3a3', '--c-text-subtle': '#737373', '--c-text-faint': '#525252', '--c-text-faintest': '#404040', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#38bdf8', '--c-accent-soft': '#0369a1', '--c-accent-text': '#7dd3fc', }, red: { '--c-surface-0': '#282828', '--c-surface-1': '#4e4e4e', '--c-surface-2': '#5a5a5a', '--c-border': '#5a5a5a', '--c-border-soft': '#6a6a6a', '--c-border-faint': '#4e4e4e', '--c-border-strong': '#b4b4b4', '--c-text': '#f0f0f0', '--c-text-secondary': '#d0d0d0', '--c-text-muted': '#b4b4b4', '--c-text-subtle': '#9a9a9a', '--c-text-faint': '#818181', '--c-text-faintest': '#6a6a6a', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#f87171', '--c-accent-soft': '#b91c1c', '--c-accent-text': '#fca5a5', }, green: { '--c-surface-0': '#1a1a1a', '--c-surface-1': '#343434', '--c-surface-2': '#3d3d3d', '--c-border': '#3d3d3d', '--c-border-soft': '#4a4a4a', '--c-border-faint': '#343434', '--c-border-strong': '#949494', '--c-text': '#ececec', '--c-text-secondary': '#bbbbbb', '--c-text-muted': '#949494', '--c-text-subtle': '#747474', '--c-text-faint': '#5c5c5c', '--c-text-faintest': '#4a4a4a', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#4ade80', '--c-accent-soft': '#15803d', '--c-accent-text': '#86efac', }, blue: { '--c-surface-0': '#363636', '--c-surface-1': '#545454', '--c-surface-2': '#666666', '--c-border': '#666666', '--c-border-soft': '#7a7a7a', '--c-border-faint': '#545454', '--c-border-strong': '#adadad', '--c-text': '#ececec', '--c-text-secondary': '#c8c8c8', '--c-text-muted': '#adadad', '--c-text-subtle': '#989898', '--c-text-faint': '#888888', '--c-text-faintest': '#7a7a7a', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#60a5fa', '--c-accent-soft': '#1d4ed8', '--c-accent-text': '#93c5fd', }, yellow: { '--c-surface-0': '#242424', '--c-surface-1': '#424242', '--c-surface-2': '#4a4a4a', '--c-border': '#4a4a4a', '--c-border-soft': '#545454', '--c-border-faint': '#424242', '--c-border-strong': '#888888', '--c-text': '#e0e0e0', '--c-text-secondary': '#a2a2a2', '--c-text-muted': '#888888', '--c-text-subtle': '#797979', '--c-text-faint': '#676767', '--c-text-faintest': '#545454', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#facc15', '--c-accent-soft': '#a16207', '--c-accent-text': '#fde047', }, pink: { '--c-surface-0': '#2c2c2c', '--c-surface-1': '#4e4e4e', '--c-surface-2': '#5a5a5a', '--c-border': '#5a5a5a', '--c-border-soft': '#6b6b6b', '--c-border-faint': '#4e4e4e', '--c-border-strong': '#b3b3b3', '--c-text': '#f2f2f2', '--c-text-secondary': '#d0d0d0', '--c-text-muted': '#b3b3b3', '--c-text-subtle': '#9a9a9a', '--c-text-faint': '#818181', '--c-text-faintest': '#6b6b6b', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#f472b6', '--c-accent-soft': '#be185d', '--c-accent-text': '#f9a8d4', }, orange: { '--c-surface-0': '#252525', '--c-surface-1': '#474747', '--c-surface-2': '#565656', '--c-border': '#565656', '--c-border-soft': '#676767', '--c-border-faint': '#474747', '--c-border-strong': '#9b9b9b', '--c-text': '#eaeaea', '--c-text-secondary': '#b8b8b8', '--c-text-muted': '#9b9b9b', '--c-text-subtle': '#888888', '--c-text-faint': '#7b7b7b', '--c-text-faintest': '#676767', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': '#fb923c', '--c-accent-soft': '#c2410c', '--c-accent-text': '#fdba74', }, } export function generateThemeCss(vars: ThemeVars): string { const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`) return `:root {\n${lines.join('\n')}\n}\n` } export interface ParsedTheme { vars: ThemeVars missing: string[] } // Parses `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload. export function parseThemeCss(content: string): ParsedTheme { const rootMatch = content.match(/:root\s*\{([^}]*)\}/) const body = rootMatch ? rootMatch[1] : content const found: Record = {} const declRegex = /(--c-[a-z0-9-]+)\s*:\s*([^;]+);/gi let match: RegExpExecArray | null while ((match = declRegex.exec(body))) { found[match[1]] = match[2].trim() } const missing = THEME_CSS_VARS.filter((name) => !(name in found)) const vars = Object.fromEntries(THEME_CSS_VARS.map((name) => [name, found[name] ?? ''])) as ThemeVars return { vars, missing } } export function downloadThemeFile(filename: string, content: string) { const blob = new Blob([content], { type: 'text/css' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = filename document.body.appendChild(link) link.click() link.remove() URL.revokeObjectURL(url) }