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
+11 -2
View File
@@ -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<LinkCssRequest>,
) -> 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(),
+26 -8
View File
@@ -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<String>,
pub path_or_url: Option<String>,
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<String>,
/// `None` means unlimited (ticket 026).
pub max_logs: Option<usize>,
/// 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<String>,
}
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<usize>`
/// 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<ColorScheme>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub max_logs: Option<Option<usize>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub custom_theme_id: Option<Option<String>>,
}
/// Distinguishes a missing JSON field (outer `None`, via `#[serde(default)]`
+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]);