Make imported/linked CSS always a full theme replacement (ticket 020)
Removes the "Use as theme" checkbox/is_theme distinction from NavMenu.tsx's CSS import and link forms — every imported/linked stylesheet is now always validated against the canonical --c-* variable set and becomes a selectable theme, same as today's is_theme:true path. Drops is_theme entirely from the backend (CssEntry, ImportCssRequest, LinkCssRequest, CssEntryDto) and from the frontend CssEntry type; an existing settings.json with a stored is_theme:false entry still deserializes fine (the field is simply unknown/ignored) and that entry becomes selectable like any other, so no migration step is needed. Also drops the now-dead always-on-overlay CSS injection path from useSettingsPanel.ts, and changes the CSS filename placeholder from "name.css" to "enter name". Implemented directly against main rather than merging the dispatched worktree branch for this ticket, since that branch's base predated ticket 012 (the custom-themes/imported-CSS consolidation) and its NavMenu structure no longer matched current main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
f20359e655
commit
d6b7f7dfb6
@@ -187,10 +187,8 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
const [maxLogsInput, setMaxLogsInput] = 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)
|
||||
@@ -254,33 +252,27 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
setCssUploadError(null)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Every imported/linked stylesheet is always a selectable theme (ticket 020), so it's always
|
||||
// validated against the canonical `--c-*` variable set before it's sent.
|
||||
async function handleCssImport(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
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
|
||||
}
|
||||
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)
|
||||
await panel.importCssEntry(cssFilename.trim(), cssContent)
|
||||
setCssFilename('')
|
||||
setCssContent('')
|
||||
setCssIsTheme(false)
|
||||
}
|
||||
|
||||
async function handleCssLink(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!cssLinkUrl.trim()) return
|
||||
await panel.linkCssEntry(cssLinkUrl.trim(), cssLinkIsTheme)
|
||||
await panel.linkCssEntry(cssLinkUrl.trim())
|
||||
setCssLinkUrl('')
|
||||
setCssLinkIsTheme(false)
|
||||
}
|
||||
|
||||
function downloadBuiltinTheme(name: string) {
|
||||
@@ -391,8 +383,8 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
<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
|
||||
imported/linked stylesheets — each one becomes a selectable alternative to the built-in colour schemes
|
||||
above
|
||||
</span>
|
||||
</div>
|
||||
{panel.cssEntries.length === 0 ? (
|
||||
@@ -405,23 +397,16 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
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>
|
||||
<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="text-[10px] uppercase text-text-faint">{entry.kind}</div>
|
||||
</div>
|
||||
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||
<button
|
||||
@@ -462,7 +447,7 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="name.css"
|
||||
placeholder="enter name"
|
||||
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"
|
||||
@@ -474,10 +459,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
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"
|
||||
@@ -496,14 +477,6 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
||||
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"
|
||||
|
||||
@@ -118,8 +118,6 @@ export interface CssEntry {
|
||||
kind: CssEntryKind
|
||||
filename: string | null
|
||||
path_or_url: string | null
|
||||
/** Whether this entry is a selectable colour-scheme theme rather than arbitrary CSS. */
|
||||
is_theme: boolean
|
||||
}
|
||||
|
||||
export interface ProfileSummary {
|
||||
@@ -348,12 +346,12 @@ export function fetchCssEntries(): Promise<CssEntry[]> {
|
||||
return getJson('/api/settings/css')
|
||||
}
|
||||
|
||||
export function importCss(filename: string, content: string, isTheme = false): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css', 'POST', { filename, content, is_theme: isTheme })
|
||||
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css', 'POST', { filename, content })
|
||||
}
|
||||
|
||||
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 linkCss(pathOrUrl: string): Promise<CssEntry> {
|
||||
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
|
||||
}
|
||||
|
||||
export function deleteCssEntry(id: string): Promise<void> {
|
||||
|
||||
@@ -17,9 +17,6 @@ import {
|
||||
type Settings,
|
||||
} from './api'
|
||||
|
||||
// `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.
|
||||
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
||||
|
||||
@@ -40,21 +37,9 @@ function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean)
|
||||
root.dataset.colorScheme = colorScheme
|
||||
}
|
||||
|
||||
// Injects a `<link>` per CSS entry, replacing any this hook previously injected. Only non-theme entries are applied unconditionally; theme entries are applied separately, only while selected — see `applyCustomThemeCss`.
|
||||
function applyCssEntries(entries: CssEntry[]) {
|
||||
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
||||
for (const entry of entries) {
|
||||
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
|
||||
if (!href) continue
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = href
|
||||
link.setAttribute(INJECTED_CSS_ATTR, entry.id)
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
}
|
||||
|
||||
// Same injection mechanism as `applyCssEntries`, kept separate since this one is conditional on `settings.custom_theme_id`; `entry` null just removes any previously-injected tag.
|
||||
// Injects a `<link>` for the active custom theme entry, if any (ticket 020: every CSS entry is
|
||||
// always a selectable theme now, so this is the only injection path — there's no more
|
||||
// always-on-overlay mode). `entry` null just removes any previously-injected tag.
|
||||
function applyCustomThemeCss(entry: CssEntry | null) {
|
||||
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
|
||||
if (!entry) return
|
||||
@@ -99,17 +84,13 @@ export function useSettingsPanel() {
|
||||
}, [refreshSettings, refreshCss, refreshProfiles])
|
||||
|
||||
const activeCustomTheme = settings?.custom_theme_id
|
||||
? cssEntries.find((entry) => entry.is_theme && entry.id === settings.custom_theme_id) ?? null
|
||||
? cssEntries.find((entry) => entry.id === settings.custom_theme_id) ?? null
|
||||
: null
|
||||
|
||||
useEffect(() => {
|
||||
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
|
||||
}, [settings, activeCustomTheme])
|
||||
|
||||
useEffect(() => {
|
||||
applyCssEntries(cssEntries.filter((entry) => !entry.is_theme))
|
||||
}, [cssEntries])
|
||||
|
||||
useEffect(() => {
|
||||
applyCustomThemeCss(activeCustomTheme)
|
||||
}, [activeCustomTheme])
|
||||
@@ -162,22 +143,20 @@ 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.
|
||||
// Every entry is always a selectable theme (ticket 020), so there's a single import path.
|
||||
const importCssEntry = useCallback(
|
||||
(filename: string, content: string, isTheme = false) =>
|
||||
(filename: string, content: string) =>
|
||||
guarded(async () => {
|
||||
await importCss(filename, content, isTheme)
|
||||
await importCss(filename, content)
|
||||
await refreshCss()
|
||||
})(),
|
||||
[guarded, refreshCss],
|
||||
)
|
||||
|
||||
const linkCssEntry = useCallback(
|
||||
(pathOrUrl: string, isTheme = false) =>
|
||||
(pathOrUrl: string) =>
|
||||
guarded(async () => {
|
||||
await linkCss(pathOrUrl, isTheme)
|
||||
await linkCss(pathOrUrl)
|
||||
await refreshCss()
|
||||
})(),
|
||||
[guarded, refreshCss],
|
||||
|
||||
Reference in New Issue
Block a user