Merge "custom themes" and "configured/imported CSS" into one mechanism (ticket 012)

The backend already stored both under one CssEntry list (Imported/Linked,
differing only by the is_theme flag), but the NavMenu Settings panel split
them into two visually-duplicated sections — "Custom themes" with its own
upload/link forms and list, and "Import CSS"/"Configured CSS" with a nearly
identical upload form and a separate list — and useSettingsPanel exposed
parallel importCssEntry/importThemeEntry and (theme-only) linkThemeEntry
wrappers around the same underlying API calls.

Consolidate to one "CSS" section: a single list of all CssEntries (each
tagged "theme" and selectable when is_theme is set), one import form and
one link form, each with a "Use as theme" checkbox instead of a duplicate
theme-only form. useSettingsPanel now exposes a single importCssEntry(name,
content, isTheme) and a generic linkCssEntry(url, isTheme) — the latter also
fixes the prior asymmetry where linking (vs. uploading) was only available
for themes, not arbitrary CSS.

No backend or settings-schema changes were needed: is_theme already
persists correctly, so existing custom-theme and imported-CSS
configuration keeps working unmodified under the unified UI.

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 18:42:40 +01:00
co-authored by Claude Sonnet 5
parent ed0be9726f
commit ffeba3c274
3 changed files with 148 additions and 209 deletions
@@ -1,14 +0,0 @@
# Merge "custom themes" and "configured/imported CSS" into one concept
Custom themes and configured/imported CSS are the same thing.
## Current behavior (notes)
- Imported CSS (as pasted content or file upload) is base64-encoded and stored in the settings file.
- The "name" field is not a file name — it's a human-readable name, labeled just "name" in the UI. It gets displayed alongside the other (built-in) themes.
- The built-in themes are also auto-inserted into the settings JSON.
- Imported CSS entries can be "deleted", which removes them from the settings JSON.
## Acceptance criteria
- The "custom themes" and "configured/imported CSS" features are consolidated into a single mechanism.
- Duplicate UI, settings, and code paths for the two are removed.
- Existing user configuration for either is migrated or aliased so it keeps working under the unified mechanism.
+139 -179
View File
@@ -184,13 +184,13 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
}
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('')
const [cssFilename, setCssFilename] = useState('')
const [cssContent, setCssContent] = useState('')
const [cssIsTheme, setCssIsTheme] = useState(false)
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
const [cssLinkUrl, setCssLinkUrl] = useState('')
const [cssLinkIsTheme, setCssLinkIsTheme] = useState(false)
const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
@@ -246,19 +246,41 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
panel.setMaxLogs(Math.floor(parsed))
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
setFilename(file.name)
setContent(await file.text())
setCssFilename(file.name)
setCssContent(await file.text())
setCssUploadError(null)
}
async function handleImport(e: React.FormEvent) {
// Single import path for both "configured/imported CSS" and "custom
// themes" (ticket 012) — `cssIsTheme` is the only thing that distinguishes
// them, so a theme upload is only additionally validated against the
// canonical `--c-*` variable set before it's sent.
async function handleCssImport(e: React.FormEvent) {
e.preventDefault()
if (!filename.trim() || !content.trim()) return
await panel.importCssEntry(filename.trim(), content)
setFilename('')
setContent('')
setCssUploadError(null)
if (!cssFilename.trim() || !cssContent.trim()) return
if (cssIsTheme) {
const { missing } = parseThemeCss(cssContent)
if (missing.length > 0) {
setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
return
}
}
await panel.importCssEntry(cssFilename.trim(), cssContent, cssIsTheme)
setCssFilename('')
setCssContent('')
setCssIsTheme(false)
}
async function handleCssLink(e: React.FormEvent) {
e.preventDefault()
if (!cssLinkUrl.trim()) return
await panel.linkCssEntry(cssLinkUrl.trim(), cssLinkIsTheme)
setCssLinkUrl('')
setCssLinkIsTheme(false)
}
function downloadBuiltinTheme(name: string) {
@@ -267,7 +289,7 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
}
async function downloadThemeEntry(entry: CssEntry) {
async function downloadCssEntry(entry: CssEntry) {
if (entry.kind === 'imported') {
const res = await fetch(cssContentUrl(entry.id))
const text = await res.text()
@@ -277,38 +299,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
}
}
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>
@@ -343,96 +333,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</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">
@@ -489,28 +389,97 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3>
<form onSubmit={handleImport} className="flex flex-col gap-2">
<div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">CSS</h3>
<span className="text-[11px] text-text-faint">
imported/linked stylesheets — check "Use as theme" to make one a selectable alternative to the built-in
colour schemes above instead of an always-on overlay
</span>
</div>
{panel.cssEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No CSS configured.</div>
) : (
<ul className="mb-2 flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0 flex-1">
{entry.is_theme ? (
<button
type="button"
onClick={() => panel.setCustomTheme(entry.id)}
className={`block w-full 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="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
)}
<div className="text-[10px] uppercase text-text-faint">
{entry.kind}
{entry.is_theme ? ' · theme' : ''}
</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => downloadCssEntry(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={handleCssImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleFileChange}
onChange={handleCssFileChange}
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="filename.css"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="name.css"
value={cssFilename}
onChange={(e) => setCssFilename(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"
/>
<textarea
placeholder="Paste CSS content here"
value={content}
onChange={(e) => setContent(e.target.value)}
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
rows={4}
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<input type="checkbox" checked={cssIsTheme} onChange={(e) => setCssIsTheme(e.target.checked)} />
Use as theme
</label>
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
@@ -518,41 +487,32 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
Import
</button>
</form>
</section>
<section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
{arbitraryCssEntries.length === 0 ? (
<div className="text-xs text-text-faint">No CSS configured.</div>
) : (
<ul className="flex flex-col gap-1">
{arbitraryCssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0">
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div>
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div>
</div>
{entry.kind === 'imported' ? (
<button
type="button"
onClick={() => panel.removeCssEntry(entry.id)}
className="ml-2 shrink-0 rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
aria-label={`Delete ${entry.filename}`}
>
delete
</button>
) : (
<span className="ml-2 shrink-0 text-text-faintest" title="Linked CSS isn't deletable here">
—
</span>
)}
</li>
))}
</ul>
)}
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<input
type="text"
placeholder="https://example.com/style.css"
value={cssLinkUrl}
onChange={(e) => setCssLinkUrl(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"
/>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1.5 text-xs text-text-muted">
<input
type="checkbox"
checked={cssLinkIsTheme}
onChange={(e) => setCssLinkIsTheme(e.target.checked)}
/>
Use as theme
</label>
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link
</button>
</div>
</form>
</section>
</div>
)
+9 -16
View File
@@ -162,28 +162,22 @@ export function useSettingsPanel() {
[guarded],
)
// One entry point for both "configured/imported CSS" and "custom themes" —
// `isTheme` is the only thing distinguishing them (ticket 012), so there's
// no separate theme-only import path.
const importCssEntry = useCallback(
(filename: string, content: string) =>
(filename: string, content: string, isTheme = false) =>
guarded(async () => {
await importCss(filename, content)
await importCss(filename, content, isTheme)
await refreshCss()
})(),
[guarded, refreshCss],
)
const importThemeEntry = useCallback(
(filename: string, content: string) =>
const linkCssEntry = useCallback(
(pathOrUrl: string, isTheme = false) =>
guarded(async () => {
await importCss(filename, content, true)
await refreshCss()
})(),
[guarded, refreshCss],
)
const linkThemeEntry = useCallback(
(pathOrUrl: string) =>
guarded(async () => {
await linkCss(pathOrUrl, true)
await linkCss(pathOrUrl, isTheme)
await refreshCss()
})(),
[guarded, refreshCss],
@@ -241,8 +235,7 @@ export function useSettingsPanel() {
setMaxLogs,
setNtfySink,
importCssEntry,
importThemeEntry,
linkThemeEntry,
linkCssEntry,
removeCssEntry,
addProfile,
removeProfile,