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
+170 -15
View File
@@ -1,10 +1,11 @@
import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api'
import type { ColorScheme } from '../lib/api'
import { cssContentUrl, type ColorScheme, type CssEntry } from '../lib/api'
import { useSettingsPanel } from '../lib/useSettingsPanel'
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
import { DocsPanel } from './Docs'
import { HelpIcon } from './HelpIcon'
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
type PanelName = 'settings' | 'presets' | 'docs' | null
@@ -146,6 +147,10 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
const [filename, setFilename] = useState('')
const [content, setContent] = useState('')
const [maxLogsInput, setMaxLogsInput] = useState('')
const [themeUploadError, setThemeUploadError] = useState<string | null>(null)
const [themeFilename, setThemeFilename] = useState('')
const [themeContent, setThemeContent] = useState('')
const [themeLinkUrl, setThemeLinkUrl] = useState('')
useEffect(() => {
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
@@ -180,28 +185,178 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
setContent('')
}
function downloadBuiltinTheme(name: string) {
const vars = BUILTIN_THEMES[name]
if (!vars) return
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
}
async function downloadThemeEntry(entry: CssEntry) {
if (entry.kind === 'imported') {
const res = await fetch(cssContentUrl(entry.id))
const text = await res.text()
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
} else if (entry.path_or_url) {
window.open(entry.path_or_url, '_blank', 'noopener')
}
}
async function handleThemeFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setThemeFilename(file.name)
setThemeContent(await file.text())
setThemeUploadError(null)
}
async function handleThemeUpload(e: React.FormEvent) {
e.preventDefault()
setThemeUploadError(null)
if (!themeFilename.trim() || !themeContent.trim()) return
const { missing } = parseThemeCss(themeContent)
if (missing.length > 0) {
setThemeUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
return
}
await panel.importThemeEntry(themeFilename.trim(), themeContent)
setThemeFilename('')
setThemeContent('')
}
async function handleThemeLink(e: React.FormEvent) {
e.preventDefault()
if (!themeLinkUrl.trim()) return
await panel.linkThemeEntry(themeLinkUrl.trim())
setThemeLinkUrl('')
}
const themeEntries = panel.cssEntries.filter((entry) => entry.is_theme)
const arbitraryCssEntries = panel.cssEntries.filter((entry) => !entry.is_theme)
return (
<div className="flex flex-col gap-5 text-sm text-text-secondary">
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
<div className="flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => (
<button
key={scheme}
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`rounded border px-2 py-1 text-xs capitalize ${
panel.settings?.color_scheme === scheme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{scheme}
</button>
<div key={scheme} className="flex items-center">
<button
type="button"
onClick={() => panel.setColorScheme(scheme)}
className={`rounded-l border px-2 py-1 text-xs capitalize ${
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
? 'border-border-strong bg-surface-2 text-text'
: 'border-border-soft text-text-muted hover:bg-surface-2'
}`}
>
{scheme}
</button>
{scheme !== 'system' && (
<button
type="button"
onClick={() => downloadBuiltinTheme(scheme)}
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
aria-label={`Download ${scheme} theme`}
title="Download as .css"
>
↓
</button>
)}
</div>
))}
</div>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Custom themes</h3>
{themeEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No custom themes yet.</div>
) : (
<ul className="mb-2 flex flex-col gap-1">
{themeEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<button
type="button"
onClick={() => panel.setCustomTheme(entry.id)}
className={`min-w-0 flex-1 truncate text-left capitalize ${
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
}`}
>
{entry.filename ?? entry.path_or_url}
</button>
<div className="ml-2 flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => downloadThemeEntry(entry)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
title="Download as .css"
>
↓
</button>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
—
</span>
)}
</div>
</li>
))}
</ul>
)}
<form onSubmit={handleThemeUpload} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleThemeFileChange}
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
/>
<input
type="text"
placeholder="theme name.css"
value={themeFilename}
onChange={(e) => setThemeFilename(e.target.value)}
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
{themeUploadError && <div className="text-xs text-danger-text-strong">{themeUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Upload theme
</button>
</form>
<form onSubmit={handleThemeLink} className="mt-2 flex gap-2">
<input
type="text"
placeholder="https://example.com/theme.css"
value={themeLinkUrl}
onChange={(e) => setThemeLinkUrl(e.target.value)}
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link theme
</button>
</form>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
<div className="flex items-center gap-2">
@@ -252,11 +407,11 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
{panel.cssEntries.length === 0 ? (
{arbitraryCssEntries.length === 0 ? (
<div className="text-xs text-text-faint">No CSS configured.</div>
) : (
<ul className="flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
{arbitraryCssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"