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
+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