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:
co-authored by
Claude Sonnet 5
parent
7bf323dd50
commit
41c9a3660b
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user