From 41c9a3660b5f46adf2aa1081d2c98738aadefb68 Mon Sep 17 00:00:00 2001 From: Hugh Kaznowski Date: Sat, 5 Sep 2026 16:41:39 +0100 Subject: [PATCH] Add downloadable/re-uploadable custom themes (ticket 033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. 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 Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o --- src/api.rs | 13 +- src/dto.rs | 34 ++- src/settings.rs | 60 +++- tickets/033-custom-theme-download-upload.md | 42 --- webapp/src/components/NavMenu.tsx | 185 +++++++++++- webapp/src/lib/api.ts | 15 +- webapp/src/lib/themes.ts | 300 ++++++++++++++++++++ webapp/src/lib/useSettingsPanel.ts | 100 ++++++- 8 files changed, 660 insertions(+), 89 deletions(-) delete mode 100644 tickets/033-custom-theme-download-upload.md create mode 100644 webapp/src/lib/themes.ts diff --git a/src/api.rs b/src/api.rs index 85cb883..116fbd8 100644 --- a/src/api.rs +++ b/src/api.rs @@ -395,7 +395,13 @@ async fn import_css( ) -> impl IntoResponse { let css_dir = state.css_dir(); let mut settings = state.settings.lock().unwrap(); - match settings.import_css(&state.settings_path, &css_dir, body.filename, &body.content) { + match settings.import_css( + &state.settings_path, + &css_dir, + body.filename, + &body.content, + body.is_theme, + ) { Ok(id) => { let entry = settings .css_entries @@ -418,7 +424,7 @@ async fn link_css( Json(body): Json, ) -> impl IntoResponse { let mut settings = state.settings.lock().unwrap(); - match settings.link_css(&state.settings_path, body.path_or_url.clone()) { + match settings.link_css(&state.settings_path, body.path_or_url.clone(), body.is_theme) { Ok(()) => { let entry = settings .css_entries @@ -564,6 +570,9 @@ async fn update_settings( if let Some(max_logs) = body.max_logs { settings.max_logs = max_logs; } + if let Some(custom_theme_id) = body.custom_theme_id { + settings.custom_theme_id = custom_theme_id; + } match settings.save(&state.settings_path) { Ok(()) => (), Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), diff --git a/src/dto.rs b/src/dto.rs index b37b597..c0b122c 100644 --- a/src/dto.rs +++ b/src/dto.rs @@ -136,18 +136,25 @@ pub struct ErrorDto { } /// Request body for `POST /api/settings/css`: raw CSS content to import and -/// store alongside the settings file. +/// store alongside the settings file. `is_theme` (ticket 033) marks this as +/// a selectable colour-scheme theme rather than arbitrary CSS; defaults to +/// `false` (arbitrary CSS) when omitted. #[derive(Debug, Deserialize)] pub struct ImportCssRequest { pub filename: String, pub content: String, + #[serde(default)] + pub is_theme: bool, } /// Request body for `POST /api/settings/css/link`: a reference to an -/// external CSS path/URL that ottyl doesn't copy or own. +/// external CSS path/URL that ottyl doesn't copy or own. `is_theme` (ticket +/// 033) marks this as a selectable theme rather than arbitrary CSS. #[derive(Debug, Deserialize)] pub struct LinkCssRequest { pub path_or_url: String, + #[serde(default)] + pub is_theme: bool, } /// Response shape for a single CSS entry, used both by the list endpoint and @@ -156,12 +163,15 @@ pub struct LinkCssRequest { /// linked path/URL for `Linked` entries (see `settings::css_entry_id`) — /// this lets the frontend key/reference both kinds of entries uniformly, /// even though only `Imported` entries are deletable/content-fetchable. +/// `is_theme` (ticket 033) distinguishes a theme entry (selectable from the +/// theme picker) from arbitrary imported/linked CSS. #[derive(Debug, Clone, Serialize)] pub struct CssEntryDto { pub id: String, pub kind: CssEntryKind, pub filename: Option, pub path_or_url: Option, + pub is_theme: bool, } #[derive(Debug, Clone, Copy, Serialize)] @@ -174,17 +184,19 @@ pub enum CssEntryKind { impl From<&CssEntry> for CssEntryDto { fn from(entry: &CssEntry) -> Self { match entry { - CssEntry::Imported { id, filename } => CssEntryDto { + CssEntry::Imported { id, filename, is_theme } => CssEntryDto { id: id.clone(), kind: CssEntryKind::Imported, filename: Some(filename.clone()), path_or_url: None, + is_theme: *is_theme, }, - CssEntry::Linked { path_or_url } => CssEntryDto { + CssEntry::Linked { path_or_url, is_theme } => CssEntryDto { id: crate::settings::css_entry_id(entry), kind: CssEntryKind::Linked, filename: None, path_or_url: Some(path_or_url.clone()), + is_theme: *is_theme, }, } } @@ -201,6 +213,9 @@ pub struct SettingsDto { pub active_profile: Option, /// `None` means unlimited (ticket 026). pub max_logs: Option, + /// Id of the selected custom theme entry, if any (ticket 033). `None` + /// means `color_scheme` alone determines the active palette. + pub custom_theme_id: Option, } impl From<&Settings> for SettingsDto { @@ -209,20 +224,23 @@ impl From<&Settings> for SettingsDto { color_scheme: settings.color_scheme, active_profile: settings.profiles.active_profile.clone(), max_logs: settings.max_logs, + custom_theme_id: settings.custom_theme_id.clone(), } } } /// Request body for `PATCH /api/settings`. `color_scheme`: `None` leaves the -/// current value untouched. `max_logs`: distinguishes "field omitted" (leave -/// unchanged, outer `None`) from "field present" (inner `Option` -/// applied verbatim — `null` clears the cap back to unlimited, a number sets -/// it), via `deserialize_double_option`. +/// current value untouched. `max_logs`/`custom_theme_id`: each distinguishes +/// "field omitted" (leave unchanged, outer `None`) from "field present" +/// (inner `Option` applied verbatim — `null` clears it, a value sets it), via +/// `deserialize_double_option`. #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { pub color_scheme: Option, #[serde(default, deserialize_with = "deserialize_double_option")] pub max_logs: Option>, + #[serde(default, deserialize_with = "deserialize_double_option")] + pub custom_theme_id: Option>, } /// Distinguishes a missing JSON field (outer `None`, via `#[serde(default)]` diff --git a/src/settings.rs b/src/settings.rs index ee126e9..e17b671 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -46,11 +46,25 @@ pub enum ColorScheme { /// the settings API since ottyl doesn't own it). /// /// Ticket 010 builds the CSS import/link HTTP API around this shape. +/// +/// `is_theme` (ticket 033) marks an entry as a full colour-scheme theme (a +/// `:root { --c-*: ...; }` variable set, selectable from the theme picker) +/// rather than arbitrary CSS that's always injected. `#[serde(default)]` so +/// existing settings.json files without the field still load as `false`. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum CssEntry { - Imported { id: String, filename: String }, - Linked { path_or_url: String }, + Imported { + id: String, + filename: String, + #[serde(default)] + is_theme: bool, + }, + Linked { + path_or_url: String, + #[serde(default)] + is_theme: bool, + }, } /// Placeholder shape for profile management (ticket 009 builds CRUD on top @@ -77,6 +91,12 @@ pub struct Profiles { pub struct Settings { pub color_scheme: ColorScheme, pub css_entries: Vec, + /// Id of the selected custom theme entry (a `css_entries` entry with + /// `is_theme: true`), if any (ticket 033). `None` means `color_scheme` + /// alone determines the active palette. `#[serde(default)]` so existing + /// settings.json files without this field still load. + #[serde(default)] + pub custom_theme_id: Option, pub profiles: Profiles, /// Maximum number of ingested logs kept in memory. `None` (the default) /// means unlimited — matches today's unbounded retention. @@ -93,6 +113,7 @@ impl Default for Settings { Settings { color_scheme: ColorScheme::default(), css_entries: Vec::new(), + custom_theme_id: None, profiles: Profiles { names: Vec::new(), active_profile: None, @@ -209,7 +230,7 @@ impl From for CssError { pub fn css_entry_id(entry: &CssEntry) -> String { match entry { CssEntry::Imported { id, .. } => id.clone(), - CssEntry::Linked { path_or_url } => linked_entry_id(path_or_url), + CssEntry::Linked { path_or_url, .. } => linked_entry_id(path_or_url), } } @@ -243,17 +264,19 @@ impl Settings { /// Imports raw CSS `content`: writes it to `/.css` (id /// generated from the filename/content/timestamp, see /// `generate_imported_css_id`), records a `CssEntry::Imported { id, - /// filename }` in `css_entries`, and persists settings to + /// filename, is_theme }` in `css_entries`, and persists settings to /// `settings_path`. Returns the generated id. /// /// `css_dir` is expected to be the settings file's parent directory - /// (i.e. CSS files live alongside `settings.json`). + /// (i.e. CSS files live alongside `settings.json`). `is_theme` (ticket + /// 033) marks this entry as a selectable theme rather than arbitrary CSS. pub fn import_css( &mut self, settings_path: &Path, css_dir: &Path, filename: String, content: &str, + is_theme: bool, ) -> Result { fs::create_dir_all(css_dir)?; let id = generate_imported_css_id(&filename, content); @@ -261,15 +284,25 @@ impl Settings { self.css_entries.push(CssEntry::Imported { id: id.clone(), filename, + is_theme, }); self.save(settings_path)?; Ok(id) } /// Registers a reference to an external CSS path/URL without copying - /// its content. Persists settings to `settings_path`. - pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> { - self.css_entries.push(CssEntry::Linked { path_or_url }); + /// its content. Persists settings to `settings_path`. `is_theme` (ticket + /// 033) marks this entry as a selectable theme rather than arbitrary CSS. + pub fn link_css( + &mut self, + settings_path: &Path, + path_or_url: String, + is_theme: bool, + ) -> Result<(), CssError> { + self.css_entries.push(CssEntry::Linked { + path_or_url, + is_theme, + }); self.save(settings_path)?; Ok(()) } @@ -364,9 +397,11 @@ mod tests { settings.css_entries.push(CssEntry::Imported { id: "abc123".to_string(), filename: "theme.css".to_string(), + is_theme: false, }); settings.css_entries.push(CssEntry::Linked { path_or_url: "https://example.com/style.css".to_string(), + is_theme: false, }); settings.profiles.names.push("default".to_string()); settings.profiles.active_profile = Some("default".to_string()); @@ -413,6 +448,7 @@ mod tests { &css_dir, "theme.css".to_string(), "body { color: red; }", + false, ) .unwrap(); @@ -425,7 +461,7 @@ mod tests { let reloaded = Settings::load(&settings_path); assert!(reloaded.css_entries.iter().any(|entry| matches!( entry, - CssEntry::Imported { id: entry_id, filename } if entry_id == &id && filename == "theme.css" + CssEntry::Imported { id: entry_id, filename, .. } if entry_id == &id && filename == "theme.css" ))); assert!(css_file.exists()); assert_eq!( @@ -442,7 +478,7 @@ mod tests { let mut settings = Settings::load(&settings_path); let id = settings - .import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}") + .import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}", false) .unwrap(); let css_file = css_dir.join(format!("{id}.css")); assert!(css_file.exists()); @@ -464,7 +500,7 @@ mod tests { let mut settings = Settings::load(&settings_path); settings - .link_css(&settings_path, "https://example.com/style.css".to_string()) + .link_css(&settings_path, "https://example.com/style.css".to_string(), false) .unwrap(); let linked_id = css_entry_id(&settings.css_entries[0]); @@ -493,7 +529,7 @@ mod tests { let mut settings = Settings::load(&settings_path); settings - .link_css(&settings_path, "https://example.com/style.css".to_string()) + .link_css(&settings_path, "https://example.com/style.css".to_string(), false) .unwrap(); let linked_id = css_entry_id(&settings.css_entries[0]); diff --git a/tickets/033-custom-theme-download-upload.md b/tickets/033-custom-theme-download-upload.md deleted file mode 100644 index 405cc2e..0000000 --- a/tickets/033-custom-theme-download-upload.md +++ /dev/null @@ -1,42 +0,0 @@ -# 033 — Downloadable and re-uploadable custom themes - -## Summary - -Let a user export the active theme's `--c-*` variable values as a file, edit -it, and load it back in as a custom theme — reusing the CSS import/link -machinery `Settings` already has (`CssEntry`, `import_css`/`link_css` in -`src/settings.rs`) rather than inventing a parallel storage mechanism. - -## Scope - -- **Download**: given a theme name (one of the built-ins from - [032](032-greyscale-theme-rework.md), or a previously-uploaded custom one), - produce a small CSS (or JSON) file containing just its `--c-*` variable - values, served for download from the webapp's theme picker. -- **Upload**: accept such a file back, validate it defines the expected set - of `--c-*` variables, and register it as a selectable custom theme — - persisted via the existing `import_css`-style flow (a new CSS entry scoped - under a `html.` selector, or a dedicated theme entry if that - shape doesn't fit `CssEntry` cleanly — decide during implementation which - reuses more of the existing settings model). -- **Linked** custom themes: since `CssEntry::Linked` already models - "reference a URL, don't own the content," a custom theme can also be - registered by URL instead of upload, consistent with how linked CSS works - today. -- Theme picker UI: list built-in themes alongside any uploaded/linked custom - ones, each with a download action. - -## Acceptance criteria - -- Downloading a theme produces a file that, re-uploaded unmodified, recreates - the same theme. -- Editing a downloaded theme's variable values and re-uploading it registers - a distinct selectable theme without touching the built-ins. -- A linked (URL) custom theme behaves like linked CSS today: not deletable - via the "remove" action that applies to uploaded ones, per - `CssError::NotImported`'s existing semantics. - -## Depends on - -- [032 — Greyscale theme rework](032-greyscale-theme-rework.md) -- [008 — Settings model](008-settings-model.md) (`CssEntry` import/link flow) diff --git a/webapp/src/components/NavMenu.tsx b/webapp/src/components/NavMenu.tsx index 10607fa..4916af3 100644 --- a/webapp/src/components/NavMenu.tsx +++ b/webapp/src/components/NavMenu.tsx @@ -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 } const [filename, setFilename] = useState('') const [content, setContent] = useState('') const [maxLogsInput, setMaxLogsInput] = useState('') + const [themeUploadError, setThemeUploadError] = useState(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 } 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) { + 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 (

Colour scheme

{COLOR_SCHEMES.map((scheme) => ( - +
+ + {scheme !== 'system' && ( + + )} +
))}
+
+

Custom themes

+ {themeEntries.length === 0 ? ( +
No custom themes yet.
+ ) : ( +
    + {themeEntries.map((entry) => ( +
  • + +
    + + {entry.kind === 'imported' ? ( + + ) : ( + + — + + )} +
    +
  • + ))} +
+ )} + +
+ + 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 &&
{themeUploadError}
} + +
+ +
+ 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" + /> + +
+
+

Log retention

@@ -252,11 +407,11 @@ function SettingsPanel({ panel }: { panel: ReturnType }

Configured CSS

- {panel.cssEntries.length === 0 ? ( + {arbitraryCssEntries.length === 0 ? (
No CSS configured.
) : (
    - {panel.cssEntries.map((entry) => ( + {arbitraryCssEntries.map((entry) => (
  • { const body: Record = { 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 { return getJson('/api/settings/css') } -export function importCss(filename: string, content: string): Promise { - return sendJson('/api/settings/css', 'POST', { filename, content }) +export function importCss(filename: string, content: string, isTheme = false): Promise { + return sendJson('/api/settings/css', 'POST', { filename, content, is_theme: isTheme }) +} + +export function linkCss(pathOrUrl: string, isTheme = false): Promise { + return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl, is_theme: isTheme }) } export function deleteCssEntry(id: string): Promise { diff --git a/webapp/src/lib/themes.ts b/webapp/src/lib/themes.ts new file mode 100644 index 0000000..347a113 --- /dev/null +++ b/webapp/src/lib/themes.ts @@ -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.` 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.` 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 = { + 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 = {} + 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) +} diff --git a/webapp/src/lib/useSettingsPanel.ts b/webapp/src/lib/useSettingsPanel.ts index f5d26f5..a8e0f11 100644 --- a/webapp/src/lib/useSettingsPanel.ts +++ b/webapp/src/lib/useSettingsPanel.ts @@ -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 `` 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.` 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.` 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 + * `` — 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) => { 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,