// A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in // a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the // built-ins, and there's no requirement that this block be the *only* thing the file contains — a // style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not // just the palette). `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', '--c-accent-2', '--c-accent-3', ] 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', '--c-accent-2': '#a78bfa', '--c-accent-3': '#4ade80', }, 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', '--c-accent-2': '#7c3aed', '--c-accent-3': '#16a34a', }, red: { '--c-surface-0': 'hsl(0 32% 10%)', '--c-surface-1': 'hsl(0 28% 16%)', '--c-surface-2': 'hsl(0 26% 22%)', '--c-border': 'hsl(0 24% 24%)', '--c-border-soft': 'hsl(0 22% 33%)', '--c-border-faint': 'hsl(0 28% 16%)', '--c-border-strong': 'hsl(0 15% 62%)', '--c-text': 'hsl(0 15% 94%)', '--c-text-secondary': 'hsl(0 12% 82%)', '--c-text-muted': 'hsl(0 10% 65%)', '--c-text-subtle': 'hsl(0 8% 50%)', '--c-text-faint': 'hsl(0 8% 38%)', '--c-text-faintest': 'hsl(0 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(180 70% 62%)', '--c-accent-soft': 'hsl(150 55% 34%)', '--c-accent-text': 'hsl(180 75% 80%)', '--c-accent-2': 'hsl(90 65% 58%)', '--c-accent-3': 'hsl(270 65% 68%)', }, green: { '--c-surface-0': 'hsl(140 32% 10%)', '--c-surface-1': 'hsl(140 28% 16%)', '--c-surface-2': 'hsl(140 26% 22%)', '--c-border': 'hsl(140 24% 24%)', '--c-border-soft': 'hsl(140 22% 33%)', '--c-border-faint': 'hsl(140 28% 16%)', '--c-border-strong': 'hsl(140 15% 62%)', '--c-text': 'hsl(140 15% 94%)', '--c-text-secondary': 'hsl(140 12% 82%)', '--c-text-muted': 'hsl(140 10% 65%)', '--c-text-subtle': 'hsl(140 8% 50%)', '--c-text-faint': 'hsl(140 8% 38%)', '--c-text-faintest': 'hsl(140 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(320 70% 62%)', '--c-accent-soft': 'hsl(290 55% 34%)', '--c-accent-text': 'hsl(320 75% 80%)', '--c-accent-2': 'hsl(230 65% 62%)', '--c-accent-3': 'hsl(50 65% 58%)', }, blue: { '--c-surface-0': 'hsl(210 32% 10%)', '--c-surface-1': 'hsl(210 28% 16%)', '--c-surface-2': 'hsl(210 26% 22%)', '--c-border': 'hsl(210 24% 24%)', '--c-border-soft': 'hsl(210 22% 33%)', '--c-border-faint': 'hsl(210 28% 16%)', '--c-border-strong': 'hsl(210 15% 62%)', '--c-text': 'hsl(210 15% 94%)', '--c-text-secondary': 'hsl(210 12% 82%)', '--c-text-muted': 'hsl(210 10% 65%)', '--c-text-subtle': 'hsl(210 8% 50%)', '--c-text-faint': 'hsl(210 8% 38%)', '--c-text-faintest': 'hsl(210 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(30 70% 62%)', '--c-accent-soft': 'hsl(0 55% 34%)', '--c-accent-text': 'hsl(30 75% 80%)', '--c-accent-2': 'hsl(300 65% 68%)', '--c-accent-3': 'hsl(120 65% 55%)', }, yellow: { '--c-surface-0': 'hsl(50 32% 10%)', '--c-surface-1': 'hsl(50 28% 16%)', '--c-surface-2': 'hsl(50 26% 22%)', '--c-border': 'hsl(50 24% 24%)', '--c-border-soft': 'hsl(50 22% 33%)', '--c-border-faint': 'hsl(50 28% 16%)', '--c-border-strong': 'hsl(50 15% 62%)', '--c-text': 'hsl(50 15% 94%)', '--c-text-secondary': 'hsl(50 12% 82%)', '--c-text-muted': 'hsl(50 10% 65%)', '--c-text-subtle': 'hsl(50 8% 50%)', '--c-text-faint': 'hsl(50 8% 38%)', '--c-text-faintest': 'hsl(50 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(230 70% 62%)', '--c-accent-soft': 'hsl(200 55% 34%)', '--c-accent-text': 'hsl(230 75% 80%)', '--c-accent-2': 'hsl(140 65% 55%)', '--c-accent-3': 'hsl(320 65% 68%)', }, pink: { '--c-surface-0': 'hsl(330 32% 10%)', '--c-surface-1': 'hsl(330 28% 16%)', '--c-surface-2': 'hsl(330 26% 22%)', '--c-border': 'hsl(330 24% 24%)', '--c-border-soft': 'hsl(330 22% 33%)', '--c-border-faint': 'hsl(330 28% 16%)', '--c-border-strong': 'hsl(330 15% 62%)', '--c-text': 'hsl(330 15% 94%)', '--c-text-secondary': 'hsl(330 12% 82%)', '--c-text-muted': 'hsl(330 10% 65%)', '--c-text-subtle': 'hsl(330 8% 50%)', '--c-text-faint': 'hsl(330 8% 38%)', '--c-text-faintest': 'hsl(330 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(150 70% 62%)', '--c-accent-soft': 'hsl(120 55% 34%)', '--c-accent-text': 'hsl(150 75% 80%)', '--c-accent-2': 'hsl(60 65% 58%)', '--c-accent-3': 'hsl(240 65% 68%)', }, orange: { '--c-surface-0': 'hsl(30 32% 10%)', '--c-surface-1': 'hsl(30 28% 16%)', '--c-surface-2': 'hsl(30 26% 22%)', '--c-border': 'hsl(30 24% 24%)', '--c-border-soft': 'hsl(30 22% 33%)', '--c-border-faint': 'hsl(30 28% 16%)', '--c-border-strong': 'hsl(30 15% 62%)', '--c-text': 'hsl(30 15% 94%)', '--c-text-secondary': 'hsl(30 12% 82%)', '--c-text-muted': 'hsl(30 10% 65%)', '--c-text-subtle': 'hsl(30 8% 50%)', '--c-text-faint': 'hsl(30 8% 38%)', '--c-text-faintest': 'hsl(30 10% 28%)', '--c-danger-bg': '#450a0a', '--c-danger-border': '#7f1d1d', '--c-danger-text': '#f87171', '--c-danger-text-strong': '#fca5a5', '--c-accent': 'hsl(210 70% 62%)', '--c-accent-soft': 'hsl(180 55% 34%)', '--c-accent-text': 'hsl(210 75% 80%)', '--c-accent-2': 'hsl(120 65% 55%)', '--c-accent-3': 'hsl(300 65% 68%)', }, light_green: { '--c-surface-0': 'hsl(140 40% 96%)', '--c-surface-1': 'hsl(140 35% 92%)', '--c-surface-2': 'hsl(140 30% 85%)', '--c-border': 'hsl(140 25% 80%)', '--c-border-soft': 'hsl(140 22% 70%)', '--c-border-faint': 'hsl(140 35% 92%)', '--c-border-strong': 'hsl(140 20% 40%)', '--c-text': 'hsl(140 30% 12%)', '--c-text-secondary': 'hsl(140 20% 25%)', '--c-text-muted': 'hsl(140 15% 40%)', '--c-text-subtle': 'hsl(140 10% 50%)', '--c-text-faint': 'hsl(140 10% 65%)', '--c-text-faintest': 'hsl(140 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(320 70% 40%)', '--c-accent-soft': 'hsl(290 60% 85%)', '--c-accent-text': 'hsl(320 75% 28%)', '--c-accent-2': 'hsl(230 70% 45%)', '--c-accent-3': 'hsl(50 70% 38%)', }, light_red: { '--c-surface-0': 'hsl(0 40% 96%)', '--c-surface-1': 'hsl(0 35% 92%)', '--c-surface-2': 'hsl(0 30% 85%)', '--c-border': 'hsl(0 25% 80%)', '--c-border-soft': 'hsl(0 22% 70%)', '--c-border-faint': 'hsl(0 35% 92%)', '--c-border-strong': 'hsl(0 20% 40%)', '--c-text': 'hsl(0 30% 12%)', '--c-text-secondary': 'hsl(0 20% 25%)', '--c-text-muted': 'hsl(0 15% 40%)', '--c-text-subtle': 'hsl(0 10% 50%)', '--c-text-faint': 'hsl(0 10% 65%)', '--c-text-faintest': 'hsl(0 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(180 70% 40%)', '--c-accent-soft': 'hsl(150 60% 85%)', '--c-accent-text': 'hsl(180 75% 28%)', '--c-accent-2': 'hsl(90 70% 45%)', '--c-accent-3': 'hsl(270 70% 38%)', }, light_blue: { '--c-surface-0': 'hsl(210 40% 96%)', '--c-surface-1': 'hsl(210 35% 92%)', '--c-surface-2': 'hsl(210 30% 85%)', '--c-border': 'hsl(210 25% 80%)', '--c-border-soft': 'hsl(210 22% 70%)', '--c-border-faint': 'hsl(210 35% 92%)', '--c-border-strong': 'hsl(210 20% 40%)', '--c-text': 'hsl(210 30% 12%)', '--c-text-secondary': 'hsl(210 20% 25%)', '--c-text-muted': 'hsl(210 15% 40%)', '--c-text-subtle': 'hsl(210 10% 50%)', '--c-text-faint': 'hsl(210 10% 65%)', '--c-text-faintest': 'hsl(210 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(30 70% 40%)', '--c-accent-soft': 'hsl(0 60% 85%)', '--c-accent-text': 'hsl(30 75% 28%)', '--c-accent-2': 'hsl(300 70% 45%)', '--c-accent-3': 'hsl(120 70% 38%)', }, light_yellow: { '--c-surface-0': 'hsl(50 40% 96%)', '--c-surface-1': 'hsl(50 35% 92%)', '--c-surface-2': 'hsl(50 30% 85%)', '--c-border': 'hsl(50 25% 80%)', '--c-border-soft': 'hsl(50 22% 70%)', '--c-border-faint': 'hsl(50 35% 92%)', '--c-border-strong': 'hsl(50 20% 40%)', '--c-text': 'hsl(50 30% 12%)', '--c-text-secondary': 'hsl(50 20% 25%)', '--c-text-muted': 'hsl(50 15% 40%)', '--c-text-subtle': 'hsl(50 10% 50%)', '--c-text-faint': 'hsl(50 10% 65%)', '--c-text-faintest': 'hsl(50 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(230 70% 40%)', '--c-accent-soft': 'hsl(200 60% 85%)', '--c-accent-text': 'hsl(230 75% 28%)', '--c-accent-2': 'hsl(140 70% 45%)', '--c-accent-3': 'hsl(320 70% 38%)', }, light_pink: { '--c-surface-0': 'hsl(330 40% 96%)', '--c-surface-1': 'hsl(330 35% 92%)', '--c-surface-2': 'hsl(330 30% 85%)', '--c-border': 'hsl(330 25% 80%)', '--c-border-soft': 'hsl(330 22% 70%)', '--c-border-faint': 'hsl(330 35% 92%)', '--c-border-strong': 'hsl(330 20% 40%)', '--c-text': 'hsl(330 30% 12%)', '--c-text-secondary': 'hsl(330 20% 25%)', '--c-text-muted': 'hsl(330 15% 40%)', '--c-text-subtle': 'hsl(330 10% 50%)', '--c-text-faint': 'hsl(330 10% 65%)', '--c-text-faintest': 'hsl(330 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(150 70% 40%)', '--c-accent-soft': 'hsl(120 60% 85%)', '--c-accent-text': 'hsl(150 75% 28%)', '--c-accent-2': 'hsl(60 70% 45%)', '--c-accent-3': 'hsl(240 70% 38%)', }, light_orange: { '--c-surface-0': 'hsl(30 40% 96%)', '--c-surface-1': 'hsl(30 35% 92%)', '--c-surface-2': 'hsl(30 30% 85%)', '--c-border': 'hsl(30 25% 80%)', '--c-border-soft': 'hsl(30 22% 70%)', '--c-border-faint': 'hsl(30 35% 92%)', '--c-border-strong': 'hsl(30 20% 40%)', '--c-text': 'hsl(30 30% 12%)', '--c-text-secondary': 'hsl(30 20% 25%)', '--c-text-muted': 'hsl(30 15% 40%)', '--c-text-subtle': 'hsl(30 10% 50%)', '--c-text-faint': 'hsl(30 10% 65%)', '--c-text-faintest': 'hsl(30 12% 80%)', '--c-danger-bg': '#fee2e2', '--c-danger-border': '#fca5a5', '--c-danger-text': '#b91c1c', '--c-danger-text-strong': '#991b1b', '--c-accent': 'hsl(210 70% 40%)', '--c-accent-soft': 'hsl(180 60% 85%)', '--c-accent-text': 'hsl(210 75% 28%)', '--c-accent-2': 'hsl(120 70% 45%)', '--c-accent-3': 'hsl(300 70% 38%)', }, } 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) }