Add downloadable/re-uploadable custom themes (ticket 033)

Themes download as a small `:root { --c-*: ...; }` CSS file (full
canonical variable set); uploading validates that set is complete and
registers the file as a CssEntry the theme picker can select. A new
`is_theme` flag on CssEntry distinguishes theme entries from arbitrary
imported/linked CSS so they don't leak into the page unconditionally
like the existing "Configured CSS" list does — a selected custom
theme's CSS is injected only while active, with built-in html.<scheme>
classes removed so its :root override isn't outranked in the cascade.
Linked (URL) custom themes reuse the existing CSS link mechanism and
inherit its non-deletable semantics.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
Hugh Kaznowski
2026-09-05 16:41:39 +01:00
co-authored by Claude Sonnet 5
parent 7bf323dd50
commit 41c9a3660b
8 changed files with 660 additions and 89 deletions
+300
View File
@@ -0,0 +1,300 @@
/**
* Downloadable/re-uploadable custom themes (ticket 033).
*
* A theme file is a small CSS file containing exactly one `:root { --c-*:
* ...; }` block defining every variable in `THEME_CSS_VARS` (the full set
* `index.css`'s `:root`/`html.<scheme>` blocks use to skin the app). Using
* `:root` (rather than a scoped selector) keeps the file trivially portable:
* the same content works whether it's applied at `:root` or scoped, and
* round-tripping a downloaded file back through upload reproduces it
* byte-for-byte-equivalent (same variable values).
*
* Applying a selected custom theme is left to `useSettingsPanel`, which
* injects the entry's CSS as an unscoped stylesheet *only* while no built-in
* `html.<scheme>` class is present — since both forms target `:root` with
* equal specificity, the later stylesheet in the cascade wins, so no
* rewriting of the file's own `:root` selector is needed.
*/
/** 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>
/**
* Built-in themes' fully-resolved variable values, matching `index.css`'s
* current cascade (a theme that doesn't override `--c-danger-*` there falls
* back to `:root`'s dark values there, so those are inlined here too — a
* downloaded built-in file is always complete, never relies on a `:root`
* fallback the download mechanism doesn't have access to).
*
* Keep in sync with `webapp/src/index.css` by hand; there's no built step
* that derives one from the other.
*/
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
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',
},
}
/** Generates a downloadable theme CSS file's content from a full variable map. */
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 a theme CSS file's content, pulling `--c-*: value;` declarations
* out of its first `:root { ... }` block (or, failing that, out of the
* whole file — tolerant of a hand-edited file that dropped the selector).
* Returns every recognized variable found plus the list of canonical
* variables (see `THEME_CSS_VARS`) that were missing, so callers can reject
* an incomplete upload with a clear error.
*/
export function parseThemeCss(content: string): ParsedTheme {
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
const body = rootMatch ? rootMatch[1] : content
const found: Record<string, string> = {}
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 }
}
/** Triggers a browser download of `content` as `filename` (a `.css` file). */
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)
}