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
+11
-2
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user