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"
+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> {
+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)
}
+92 -8
View File
@@ -9,6 +9,7 @@ import {
fetchProfiles,
fetchSettings,
importCss,
linkCss,
updateSettings,
type ColorScheme,
type CssEntry,
@@ -19,6 +20,9 @@ import {
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
/** `data-*` marker for the single injected `<link>` for the active custom theme, if any (ticket 033). */
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
@@ -29,12 +33,22 @@ const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow'
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
* custom property keyed off that class (see `index.css`), so switching it
* re-skins the whole app, not just the base page background.
*
* `customThemeActive` (ticket 033): when a custom theme is selected, no
* built-in `html.<scheme>` class is added at all — its own injected `:root`
* override (see `applyCustomThemeCss`) is the only thing setting the
* `--c-*` variables, and adding a built-in class alongside it would win the
* cascade (a class selector is more specific than `:root`) and mask it.
*/
function applyColorScheme(colorScheme: ColorScheme) {
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
const root = document.documentElement
root.classList.remove(...THEME_CLASSES)
if (customThemeActive) {
root.dataset.colorScheme = 'custom'
return
}
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
root.classList.remove(...THEME_CLASSES)
root.classList.add(effective)
root.dataset.colorScheme = colorScheme
}
@@ -45,6 +59,10 @@ function applyColorScheme(colorScheme: ColorScheme) {
* their `path_or_url`), after removing any tags this hook previously
* injected — so the rendered app reflects the current entry list immediately
* on every change, without a page reload.
*
* Only arbitrary (non-theme) entries are applied unconditionally this way;
* theme entries (`is_theme: true`) are applied separately and only while
* selected, see `applyCustomThemeCss`.
*/
function applyCssEntries(entries: CssEntry[]) {
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
@@ -59,6 +77,25 @@ function applyCssEntries(entries: CssEntry[]) {
}
}
/**
* Injects the single active custom theme's CSS (ticket 033) as a
* `<link>` — same mechanism as `applyCssEntries`, kept separate since this
* one is conditional on `settings.custom_theme_id`, not always-on. `entry`
* is `null` when no custom theme is selected, which just removes any
* previously-injected tag.
*/
function applyCustomThemeCss(entry: CssEntry | null) {
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
if (!entry) return
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
if (!href) return
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.setAttribute(INJECTED_THEME_ATTR, entry.id)
document.head.appendChild(link)
}
/**
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
* (ticket 011), and applies the colour scheme / imported CSS to the document
@@ -95,14 +132,22 @@ export function useSettingsPanel() {
refreshProfiles().catch(() => {})
}, [refreshSettings, refreshCss, refreshProfiles])
useEffect(() => {
if (settings) applyColorScheme(settings.color_scheme)
}, [settings])
const activeCustomTheme = settings?.custom_theme_id
? cssEntries.find((entry) => entry.is_theme && entry.id === settings.custom_theme_id) ?? null
: null
useEffect(() => {
applyCssEntries(cssEntries)
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
}, [settings, activeCustomTheme])
useEffect(() => {
applyCssEntries(cssEntries.filter((entry) => !entry.is_theme))
}, [cssEntries])
useEffect(() => {
applyCustomThemeCss(activeCustomTheme)
}, [activeCustomTheme])
const guarded = useCallback((action: () => Promise<void>) => {
return async () => {
try {
@@ -117,7 +162,18 @@ export function useSettingsPanel() {
const setColorScheme = useCallback(
(colorScheme: ColorScheme) =>
guarded(async () => {
const next = await updateSettings({ colorScheme })
// Selecting a built-in scheme always deselects any custom theme —
// the two are mutually exclusive (see `applyColorScheme`).
const next = await updateSettings({ colorScheme, customThemeId: null })
setSettings(next)
})(),
[guarded],
)
const setCustomTheme = useCallback(
(id: string) =>
guarded(async () => {
const next = await updateSettings({ customThemeId: id })
setSettings(next)
})(),
[guarded],
@@ -141,13 +197,37 @@ export function useSettingsPanel() {
[guarded, refreshCss],
)
const importThemeEntry = useCallback(
(filename: string, content: string) =>
guarded(async () => {
await importCss(filename, content, true)
await refreshCss()
})(),
[guarded, refreshCss],
)
const linkThemeEntry = useCallback(
(pathOrUrl: string) =>
guarded(async () => {
await linkCss(pathOrUrl, true)
await refreshCss()
})(),
[guarded, refreshCss],
)
const removeCssEntry = useCallback(
(id: string) =>
guarded(async () => {
await deleteCssEntry(id)
await refreshCss()
// The removed entry may have been the active custom theme — fall
// back to `color_scheme` rather than leaving a dangling selection.
if (settings?.custom_theme_id === id) {
const next = await updateSettings({ customThemeId: null })
setSettings(next)
}
})(),
[guarded, refreshCss],
[guarded, refreshCss, settings],
)
const addProfile = useCallback(
@@ -180,11 +260,15 @@ export function useSettingsPanel() {
return {
settings,
cssEntries,
activeCustomTheme,
profiles,
error,
setColorScheme,
setCustomTheme,
setMaxLogs,
importCssEntry,
importThemeEntry,
linkThemeEntry,
removeCssEntry,
addProfile,
removeProfile,