Merge ticket/004-012-theme-consolidation

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:43:04 +01:00
co-authored by Claude Sonnet 5
8 changed files with 171 additions and 269 deletions
+15 -1
View File
@@ -18,12 +18,17 @@ use crate::alerts::AlertRule;
pub type SettingsHandle = Arc<Mutex<Settings>>; pub type SettingsHandle = Arc<Mutex<Settings>>;
/// User's preferred colour scheme for the webapp. /// User's preferred colour scheme for the webapp.
///
/// `Dark` accepts the legacy `"grey"` serde value (ticket 004: "grey" was a
/// near-duplicate of "dark" and has been removed as a selectable option) so a
/// settings file saved before that change still loads cleanly instead of
/// falling back to `System`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum ColorScheme { pub enum ColorScheme {
Light, Light,
#[serde(alias = "grey")]
Dark, Dark,
Grey,
Red, Red,
Green, Green,
Blue, Blue,
@@ -316,6 +321,15 @@ mod tests {
assert_eq!(reloaded, settings); assert_eq!(reloaded, settings);
} }
#[test]
fn legacy_grey_color_scheme_deserializes_as_dark() {
let settings: Settings = serde_json::from_str(
r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#,
)
.unwrap();
assert_eq!(settings.color_scheme, ColorScheme::Dark);
}
#[test] #[test]
fn round_trips_through_save_and_load() { fn round_trips_through_save_and_load() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -1,8 +0,0 @@
# Remove duplicate "grey" theme, keep "dark"
Dark and grey are the same thing. Keep dark, remove grey.
## Acceptance criteria
- The "grey" theme option is removed from theme selection UI and settings.
- Any references/config values for "grey" are migrated or aliased to "dark" so existing saved settings don't break.
- "dark" remains as the single equivalent theme.
@@ -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.
+136 -177
View File
@@ -21,7 +21,6 @@ const COLOR_SCHEMES: ColorScheme[] = [
'system', 'system',
'light', 'light',
'dark', 'dark',
'grey',
'red', 'red',
'green', 'green',
'blue', 'blue',
@@ -185,13 +184,13 @@ function PresetsDropdown({ panel }: { panel: ReturnType<typeof useSettingsPanel>
} }
function SettingsPanel({ 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 [maxLogsInput, setMaxLogsInput] = useState('')
const [themeUploadError, setThemeUploadError] = useState<string | null>(null) const [cssFilename, setCssFilename] = useState('')
const [themeFilename, setThemeFilename] = useState('') const [cssContent, setCssContent] = useState('')
const [themeContent, setThemeContent] = useState('') const [cssIsTheme, setCssIsTheme] = useState(false)
const [themeLinkUrl, setThemeLinkUrl] = useState('') const [cssUploadError, setCssUploadError] = useState<string | null>(null)
const [cssLinkUrl, setCssLinkUrl] = useState('')
const [cssLinkIsTheme, setCssLinkIsTheme] = useState(false)
const [ntfyTopicInput, setNtfyTopicInput] = useState('') const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('') const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null) const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
@@ -247,19 +246,41 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
panel.setMaxLogs(Math.floor(parsed)) 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] const file = e.target.files?.[0]
if (!file) return if (!file) return
setFilename(file.name) setCssFilename(file.name)
setContent(await file.text()) 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() e.preventDefault()
if (!filename.trim() || !content.trim()) return setCssUploadError(null)
await panel.importCssEntry(filename.trim(), content) if (!cssFilename.trim() || !cssContent.trim()) return
setFilename('') if (cssIsTheme) {
setContent('') 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) { function downloadBuiltinTheme(name: string) {
@@ -268,7 +289,7 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
downloadThemeFile(`${name}.css`, generateThemeCss(vars)) downloadThemeFile(`${name}.css`, generateThemeCss(vars))
} }
async function downloadThemeEntry(entry: CssEntry) { async function downloadCssEntry(entry: CssEntry) {
if (entry.kind === 'imported') { if (entry.kind === 'imported') {
const res = await fetch(cssContentUrl(entry.id)) const res = await fetch(cssContentUrl(entry.id))
const text = await res.text() const text = await res.text()
@@ -278,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 ( return (
<div className="flex flex-col gap-5 text-sm text-text-secondary"> <div className="flex flex-col gap-5 text-sm text-text-secondary">
<section> <section>
@@ -344,96 +333,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</div> </div>
</section> </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> <section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3> <h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -490,28 +389,97 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
</section> </section>
<section> <section>
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Import CSS</h3> <div className="mb-2 flex items-center gap-1.5">
<form onSubmit={handleImport} className="flex flex-col gap-2"> <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 <input
type="file" type="file"
accept=".css,text/css" 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" 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 <input
type="text" type="text"
placeholder="filename.css" placeholder="name.css"
value={filename} value={cssFilename}
onChange={(e) => setFilename(e.target.value)} 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" 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 <textarea
placeholder="Paste CSS content here" placeholder="Paste CSS content here"
value={content} value={cssContent}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setCssContent(e.target.value)}
rows={4} 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" 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 <button
type="submit" type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2" className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
@@ -519,41 +487,32 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
Import Import
</button> </button>
</form> </form>
</section>
<section> <form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3> <input
{arbitraryCssEntries.length === 0 ? ( type="text"
<div className="text-xs text-text-faint">No CSS configured.</div> placeholder="https://example.com/style.css"
) : ( value={cssLinkUrl}
<ul className="flex flex-col gap-1"> onChange={(e) => setCssLinkUrl(e.target.value)}
{arbitraryCssEntries.map((entry) => ( 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"
<li />
key={entry.id} <div className="flex items-center gap-2">
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs" <label className="flex items-center gap-1.5 text-xs text-text-muted">
> <input
<div className="min-w-0"> type="checkbox"
<div className="truncate text-text">{entry.filename ?? entry.path_or_url}</div> checked={cssLinkIsTheme}
<div className="text-[10px] uppercase text-text-faint">{entry.kind}</div> onChange={(e) => setCssLinkIsTheme(e.target.checked)}
</div> />
{entry.kind === 'imported' ? ( Use as theme
</label>
<button <button
type="button" type="submit"
onClick={() => panel.removeCssEntry(entry.id)} className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
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 Link
</button> </button>
) : ( </div>
<span className="ml-2 shrink-0 text-text-faintest" title="Linked CSS isn't deletable here"> </form>
—
</span>
)}
</li>
))}
</ul>
)}
</section> </section>
</div> </div>
) )
+7 -26
View File
@@ -16,8 +16,13 @@
* so re-declaring the `--c-*` variables under a `html.<theme>` selector * so re-declaring the `--c-*` variables under a `html.<theme>` selector
* re-skins the whole app with no per-component changes. * re-skins the whole app with no per-component changes.
* *
* Derivation: `dark`/`grey` share one true black-based neutral (S=0) scale, * Derivation: `dark` uses one true black-based neutral (S=0) scale, `light`
* `light` is its exact lightness mirror — these three stay grey throughout. * is its exact lightness mirror — these two stay grey throughout. (There
* used to be a separate `grey` theme sharing `dark`'s neutral scale with
* only its accent trio differing; it was a near-duplicate of `dark` and was
* removed in favour of a single `dark` theme — ticket 004. Settings saved
* with `color_scheme: "grey"` still load fine: the backend aliases it to
* `dark`, see `ColorScheme` in `src/settings.rs`.)
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its * Each named theme (red/green/blue/yellow/pink/orange) instead tints its
* whole surface/border/text scale in its own hue at low saturation (so the * whole surface/border/text scale in its own hue at low saturation (so the
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and * chrome itself reads as "a shade of red", not grey-with-a-red-light), and
@@ -88,30 +93,6 @@ html.light {
--c-accent-text: #0369a1; --c-accent-text: #0369a1;
} }
/* Canonical greyscale theme: identical neutral scale to :root (dark), so
every other theme's derivation above is measured against this one. */
html.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-accent: #38bdf8;
--c-accent-soft: #0369a1;
--c-accent-text: #7dd3fc;
}
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180 /* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
(cyan), accent-soft composite 150 (spring green) */ (cyan), accent-soft composite 150 (spring green) */
html.red { html.red {
-1
View File
@@ -85,7 +85,6 @@ export type ColorScheme =
| 'light' | 'light'
| 'dark' | 'dark'
| 'system' | 'system'
| 'grey'
| 'red' | 'red'
| 'green' | 'green'
| 'blue' | 'blue'
-22
View File
@@ -72,28 +72,6 @@ export const BUILTIN_THEMES: Record<string, ThemeVars> = {
'--c-accent-soft': '#7dd3fc', '--c-accent-soft': '#7dd3fc',
'--c-accent-text': '#0369a1', '--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: { red: {
'--c-surface-0': 'hsl(0 32% 10%)', '--c-surface-0': 'hsl(0 32% 10%)',
'--c-surface-1': 'hsl(0 28% 16%)', '--c-surface-1': 'hsl(0 28% 16%)',
+10 -17
View File
@@ -24,7 +24,7 @@ const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id' const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. // 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 const THEME_CLASSES = ['light', 'dark', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it. // Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it.
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) { function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
@@ -162,28 +162,22 @@ export function useSettingsPanel() {
[guarded], [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( const importCssEntry = useCallback(
(filename: string, content: string) => (filename: string, content: string, isTheme = false) =>
guarded(async () => { guarded(async () => {
await importCss(filename, content) await importCss(filename, content, isTheme)
await refreshCss() await refreshCss()
})(), })(),
[guarded, refreshCss], [guarded, refreshCss],
) )
const importThemeEntry = useCallback( const linkCssEntry = useCallback(
(filename: string, content: string) => (pathOrUrl: string, isTheme = false) =>
guarded(async () => { guarded(async () => {
await importCss(filename, content, true) await linkCss(pathOrUrl, isTheme)
await refreshCss()
})(),
[guarded, refreshCss],
)
const linkThemeEntry = useCallback(
(pathOrUrl: string) =>
guarded(async () => {
await linkCss(pathOrUrl, true)
await refreshCss() await refreshCss()
})(), })(),
[guarded, refreshCss], [guarded, refreshCss],
@@ -241,8 +235,7 @@ export function useSettingsPanel() {
setMaxLogs, setMaxLogs,
setNtfySink, setNtfySink,
importCssEntry, importCssEntry,
importThemeEntry, linkCssEntry,
linkThemeEntry,
removeCssEntry, removeCssEntry,
addProfile, addProfile,
removeProfile, removeProfile,