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
+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)]`