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
+48 -12
View File
@@ -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<CssEntry>,
/// 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<String>,
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<io::Error> 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_dir>/<id>.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<String, CssError> {
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]);