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
+13 -2
View File
@@ -103,6 +103,8 @@ export interface Settings {
active_profile: string | null
/** Maximum ingested logs kept in memory; `null` means unlimited. */
max_logs: number | null
/** Id of the selected custom theme entry (ticket 033), or `null` if `color_scheme` alone applies. */
custom_theme_id: string | null
}
export type CssEntryKind = 'imported' | 'linked'
@@ -112,6 +114,8 @@ export interface CssEntry {
kind: CssEntryKind
filename: string | null
path_or_url: string | null
/** Whether this entry is a selectable colour-scheme theme (ticket 033) rather than arbitrary CSS. */
is_theme: boolean
}
export interface ProfileSummary {
@@ -304,9 +308,12 @@ export function updateSettings(update: {
colorScheme?: ColorScheme
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
maxLogs?: number | null
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
customThemeId?: string | null
}): Promise<Settings> {
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
if ('maxLogs' in update) body.max_logs = update.maxLogs
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
return sendJson('/api/settings', 'PATCH', body)
}
@@ -314,8 +321,12 @@ export function fetchCssEntries(): Promise<CssEntry[]> {
return getJson('/api/settings/css')
}
export function importCss(filename: string, content: string): Promise<CssEntry> {
return sendJson('/api/settings/css', 'POST', { filename, content })
export function importCss(filename: string, content: string, isTheme = false): Promise<CssEntry> {
return sendJson('/api/settings/css', 'POST', { filename, content, is_theme: isTheme })
}
export function linkCss(pathOrUrl: string, isTheme = false): Promise<CssEntry> {
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl, is_theme: isTheme })
}
export function deleteCssEntry(id: string): Promise<void> {