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 {
|
) -> impl IntoResponse {
|
||||||
let css_dir = state.css_dir();
|
let css_dir = state.css_dir();
|
||||||
let mut settings = state.settings.lock().unwrap();
|
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) => {
|
Ok(id) => {
|
||||||
let entry = settings
|
let entry = settings
|
||||||
.css_entries
|
.css_entries
|
||||||
@@ -418,7 +424,7 @@ async fn link_css(
|
|||||||
Json(body): Json<LinkCssRequest>,
|
Json(body): Json<LinkCssRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let mut settings = state.settings.lock().unwrap();
|
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(()) => {
|
Ok(()) => {
|
||||||
let entry = settings
|
let entry = settings
|
||||||
.css_entries
|
.css_entries
|
||||||
@@ -564,6 +570,9 @@ async fn update_settings(
|
|||||||
if let Some(max_logs) = body.max_logs {
|
if let Some(max_logs) = body.max_logs {
|
||||||
settings.max_logs = 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) {
|
match settings.save(&state.settings_path) {
|
||||||
Ok(()) => (),
|
Ok(()) => (),
|
||||||
Err(err) => return (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(),
|
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
|
/// 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct ImportCssRequest {
|
pub struct ImportCssRequest {
|
||||||
pub filename: String,
|
pub filename: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub is_theme: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `POST /api/settings/css/link`: a reference to an
|
/// 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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct LinkCssRequest {
|
pub struct LinkCssRequest {
|
||||||
pub path_or_url: String,
|
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
|
/// 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`) —
|
/// linked path/URL for `Linked` entries (see `settings::css_entry_id`) —
|
||||||
/// this lets the frontend key/reference both kinds of entries uniformly,
|
/// this lets the frontend key/reference both kinds of entries uniformly,
|
||||||
/// even though only `Imported` entries are deletable/content-fetchable.
|
/// 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)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct CssEntryDto {
|
pub struct CssEntryDto {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind: CssEntryKind,
|
pub kind: CssEntryKind,
|
||||||
pub filename: Option<String>,
|
pub filename: Option<String>,
|
||||||
pub path_or_url: Option<String>,
|
pub path_or_url: Option<String>,
|
||||||
|
pub is_theme: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize)]
|
#[derive(Debug, Clone, Copy, Serialize)]
|
||||||
@@ -174,17 +184,19 @@ pub enum CssEntryKind {
|
|||||||
impl From<&CssEntry> for CssEntryDto {
|
impl From<&CssEntry> for CssEntryDto {
|
||||||
fn from(entry: &CssEntry) -> Self {
|
fn from(entry: &CssEntry) -> Self {
|
||||||
match entry {
|
match entry {
|
||||||
CssEntry::Imported { id, filename } => CssEntryDto {
|
CssEntry::Imported { id, filename, is_theme } => CssEntryDto {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
kind: CssEntryKind::Imported,
|
kind: CssEntryKind::Imported,
|
||||||
filename: Some(filename.clone()),
|
filename: Some(filename.clone()),
|
||||||
path_or_url: None,
|
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),
|
id: crate::settings::css_entry_id(entry),
|
||||||
kind: CssEntryKind::Linked,
|
kind: CssEntryKind::Linked,
|
||||||
filename: None,
|
filename: None,
|
||||||
path_or_url: Some(path_or_url.clone()),
|
path_or_url: Some(path_or_url.clone()),
|
||||||
|
is_theme: *is_theme,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,6 +213,9 @@ pub struct SettingsDto {
|
|||||||
pub active_profile: Option<String>,
|
pub active_profile: Option<String>,
|
||||||
/// `None` means unlimited (ticket 026).
|
/// `None` means unlimited (ticket 026).
|
||||||
pub max_logs: Option<usize>,
|
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 {
|
impl From<&Settings> for SettingsDto {
|
||||||
@@ -209,20 +224,23 @@ impl From<&Settings> for SettingsDto {
|
|||||||
color_scheme: settings.color_scheme,
|
color_scheme: settings.color_scheme,
|
||||||
active_profile: settings.profiles.active_profile.clone(),
|
active_profile: settings.profiles.active_profile.clone(),
|
||||||
max_logs: settings.max_logs,
|
max_logs: settings.max_logs,
|
||||||
|
custom_theme_id: settings.custom_theme_id.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Request body for `PATCH /api/settings`. `color_scheme`: `None` leaves the
|
/// Request body for `PATCH /api/settings`. `color_scheme`: `None` leaves the
|
||||||
/// current value untouched. `max_logs`: distinguishes "field omitted" (leave
|
/// current value untouched. `max_logs`/`custom_theme_id`: each distinguishes
|
||||||
/// unchanged, outer `None`) from "field present" (inner `Option<usize>`
|
/// "field omitted" (leave unchanged, outer `None`) from "field present"
|
||||||
/// applied verbatim — `null` clears the cap back to unlimited, a number sets
|
/// (inner `Option` applied verbatim — `null` clears it, a value sets it), via
|
||||||
/// it), via `deserialize_double_option`.
|
/// `deserialize_double_option`.
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdateSettingsRequest {
|
pub struct UpdateSettingsRequest {
|
||||||
pub color_scheme: Option<ColorScheme>,
|
pub color_scheme: Option<ColorScheme>,
|
||||||
#[serde(default, deserialize_with = "deserialize_double_option")]
|
#[serde(default, deserialize_with = "deserialize_double_option")]
|
||||||
pub max_logs: Option<Option<usize>>,
|
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)]`
|
/// 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).
|
/// the settings API since ottyl doesn't own it).
|
||||||
///
|
///
|
||||||
/// Ticket 010 builds the CSS import/link HTTP API around this shape.
|
/// 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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "kind", rename_all = "lowercase")]
|
#[serde(tag = "kind", rename_all = "lowercase")]
|
||||||
pub enum CssEntry {
|
pub enum CssEntry {
|
||||||
Imported { id: String, filename: String },
|
Imported {
|
||||||
Linked { path_or_url: String },
|
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
|
/// Placeholder shape for profile management (ticket 009 builds CRUD on top
|
||||||
@@ -77,6 +91,12 @@ pub struct Profiles {
|
|||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub color_scheme: ColorScheme,
|
pub color_scheme: ColorScheme,
|
||||||
pub css_entries: Vec<CssEntry>,
|
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,
|
pub profiles: Profiles,
|
||||||
/// Maximum number of ingested logs kept in memory. `None` (the default)
|
/// Maximum number of ingested logs kept in memory. `None` (the default)
|
||||||
/// means unlimited — matches today's unbounded retention.
|
/// means unlimited — matches today's unbounded retention.
|
||||||
@@ -93,6 +113,7 @@ impl Default for Settings {
|
|||||||
Settings {
|
Settings {
|
||||||
color_scheme: ColorScheme::default(),
|
color_scheme: ColorScheme::default(),
|
||||||
css_entries: Vec::new(),
|
css_entries: Vec::new(),
|
||||||
|
custom_theme_id: None,
|
||||||
profiles: Profiles {
|
profiles: Profiles {
|
||||||
names: Vec::new(),
|
names: Vec::new(),
|
||||||
active_profile: None,
|
active_profile: None,
|
||||||
@@ -209,7 +230,7 @@ impl From<io::Error> for CssError {
|
|||||||
pub fn css_entry_id(entry: &CssEntry) -> String {
|
pub fn css_entry_id(entry: &CssEntry) -> String {
|
||||||
match entry {
|
match entry {
|
||||||
CssEntry::Imported { id, .. } => id.clone(),
|
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
|
/// Imports raw CSS `content`: writes it to `<css_dir>/<id>.css` (id
|
||||||
/// generated from the filename/content/timestamp, see
|
/// generated from the filename/content/timestamp, see
|
||||||
/// `generate_imported_css_id`), records a `CssEntry::Imported { id,
|
/// `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.
|
/// `settings_path`. Returns the generated id.
|
||||||
///
|
///
|
||||||
/// `css_dir` is expected to be the settings file's parent directory
|
/// `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(
|
pub fn import_css(
|
||||||
&mut self,
|
&mut self,
|
||||||
settings_path: &Path,
|
settings_path: &Path,
|
||||||
css_dir: &Path,
|
css_dir: &Path,
|
||||||
filename: String,
|
filename: String,
|
||||||
content: &str,
|
content: &str,
|
||||||
|
is_theme: bool,
|
||||||
) -> Result<String, CssError> {
|
) -> Result<String, CssError> {
|
||||||
fs::create_dir_all(css_dir)?;
|
fs::create_dir_all(css_dir)?;
|
||||||
let id = generate_imported_css_id(&filename, content);
|
let id = generate_imported_css_id(&filename, content);
|
||||||
@@ -261,15 +284,25 @@ impl Settings {
|
|||||||
self.css_entries.push(CssEntry::Imported {
|
self.css_entries.push(CssEntry::Imported {
|
||||||
id: id.clone(),
|
id: id.clone(),
|
||||||
filename,
|
filename,
|
||||||
|
is_theme,
|
||||||
});
|
});
|
||||||
self.save(settings_path)?;
|
self.save(settings_path)?;
|
||||||
Ok(id)
|
Ok(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers a reference to an external CSS path/URL without copying
|
/// Registers a reference to an external CSS path/URL without copying
|
||||||
/// its content. Persists settings to `settings_path`.
|
/// its content. Persists settings to `settings_path`. `is_theme` (ticket
|
||||||
pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> {
|
/// 033) marks this entry as a selectable theme rather than arbitrary CSS.
|
||||||
self.css_entries.push(CssEntry::Linked { path_or_url });
|
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)?;
|
self.save(settings_path)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -364,9 +397,11 @@ mod tests {
|
|||||||
settings.css_entries.push(CssEntry::Imported {
|
settings.css_entries.push(CssEntry::Imported {
|
||||||
id: "abc123".to_string(),
|
id: "abc123".to_string(),
|
||||||
filename: "theme.css".to_string(),
|
filename: "theme.css".to_string(),
|
||||||
|
is_theme: false,
|
||||||
});
|
});
|
||||||
settings.css_entries.push(CssEntry::Linked {
|
settings.css_entries.push(CssEntry::Linked {
|
||||||
path_or_url: "https://example.com/style.css".to_string(),
|
path_or_url: "https://example.com/style.css".to_string(),
|
||||||
|
is_theme: false,
|
||||||
});
|
});
|
||||||
settings.profiles.names.push("default".to_string());
|
settings.profiles.names.push("default".to_string());
|
||||||
settings.profiles.active_profile = Some("default".to_string());
|
settings.profiles.active_profile = Some("default".to_string());
|
||||||
@@ -413,6 +448,7 @@ mod tests {
|
|||||||
&css_dir,
|
&css_dir,
|
||||||
"theme.css".to_string(),
|
"theme.css".to_string(),
|
||||||
"body { color: red; }",
|
"body { color: red; }",
|
||||||
|
false,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -425,7 +461,7 @@ mod tests {
|
|||||||
let reloaded = Settings::load(&settings_path);
|
let reloaded = Settings::load(&settings_path);
|
||||||
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
assert!(reloaded.css_entries.iter().any(|entry| matches!(
|
||||||
entry,
|
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!(css_file.exists());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -442,7 +478,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut settings = Settings::load(&settings_path);
|
let mut settings = Settings::load(&settings_path);
|
||||||
let id = settings
|
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();
|
.unwrap();
|
||||||
let css_file = css_dir.join(format!("{id}.css"));
|
let css_file = css_dir.join(format!("{id}.css"));
|
||||||
assert!(css_file.exists());
|
assert!(css_file.exists());
|
||||||
@@ -464,7 +500,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut settings = Settings::load(&settings_path);
|
let mut settings = Settings::load(&settings_path);
|
||||||
settings
|
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();
|
.unwrap();
|
||||||
let linked_id = css_entry_id(&settings.css_entries[0]);
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||||
|
|
||||||
@@ -493,7 +529,7 @@ mod tests {
|
|||||||
|
|
||||||
let mut settings = Settings::load(&settings_path);
|
let mut settings = Settings::load(&settings_path);
|
||||||
settings
|
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();
|
.unwrap();
|
||||||
let linked_id = css_entry_id(&settings.css_entries[0]);
|
let linked_id = css_entry_id(&settings.css_entries[0]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# 033 — Downloadable and re-uploadable custom themes
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
Let a user export the active theme's `--c-*` variable values as a file, edit
|
|
||||||
it, and load it back in as a custom theme — reusing the CSS import/link
|
|
||||||
machinery `Settings` already has (`CssEntry`, `import_css`/`link_css` in
|
|
||||||
`src/settings.rs`) rather than inventing a parallel storage mechanism.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
- **Download**: given a theme name (one of the built-ins from
|
|
||||||
[032](032-greyscale-theme-rework.md), or a previously-uploaded custom one),
|
|
||||||
produce a small CSS (or JSON) file containing just its `--c-*` variable
|
|
||||||
values, served for download from the webapp's theme picker.
|
|
||||||
- **Upload**: accept such a file back, validate it defines the expected set
|
|
||||||
of `--c-*` variables, and register it as a selectable custom theme —
|
|
||||||
persisted via the existing `import_css`-style flow (a new CSS entry scoped
|
|
||||||
under a `html.<custom-name>` selector, or a dedicated theme entry if that
|
|
||||||
shape doesn't fit `CssEntry` cleanly — decide during implementation which
|
|
||||||
reuses more of the existing settings model).
|
|
||||||
- **Linked** custom themes: since `CssEntry::Linked` already models
|
|
||||||
"reference a URL, don't own the content," a custom theme can also be
|
|
||||||
registered by URL instead of upload, consistent with how linked CSS works
|
|
||||||
today.
|
|
||||||
- Theme picker UI: list built-in themes alongside any uploaded/linked custom
|
|
||||||
ones, each with a download action.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
|
|
||||||
- Downloading a theme produces a file that, re-uploaded unmodified, recreates
|
|
||||||
the same theme.
|
|
||||||
- Editing a downloaded theme's variable values and re-uploading it registers
|
|
||||||
a distinct selectable theme without touching the built-ins.
|
|
||||||
- A linked (URL) custom theme behaves like linked CSS today: not deletable
|
|
||||||
via the "remove" action that applies to uploaded ones, per
|
|
||||||
`CssError::NotImported`'s existing semantics.
|
|
||||||
|
|
||||||
## Depends on
|
|
||||||
|
|
||||||
- [032 — Greyscale theme rework](032-greyscale-theme-rework.md)
|
|
||||||
- [008 — Settings model](008-settings-model.md) (`CssEntry` import/link flow)
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { Stats } from '../lib/api'
|
import type { Stats } from '../lib/api'
|
||||||
import type { ColorScheme } from '../lib/api'
|
import { cssContentUrl, type ColorScheme, type CssEntry } from '../lib/api'
|
||||||
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
import { useSettingsPanel } from '../lib/useSettingsPanel'
|
||||||
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
import { useDocsNav, type DocsTarget } from '../lib/docsNav'
|
||||||
import { DocsPanel } from './Docs'
|
import { DocsPanel } from './Docs'
|
||||||
import { HelpIcon } from './HelpIcon'
|
import { HelpIcon } from './HelpIcon'
|
||||||
|
import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes'
|
||||||
|
|
||||||
type PanelName = 'settings' | 'presets' | 'docs' | null
|
type PanelName = 'settings' | 'presets' | 'docs' | null
|
||||||
|
|
||||||
@@ -146,6 +147,10 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
const [filename, setFilename] = useState('')
|
const [filename, setFilename] = useState('')
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
const [maxLogsInput, setMaxLogsInput] = useState('')
|
const [maxLogsInput, setMaxLogsInput] = useState('')
|
||||||
|
const [themeUploadError, setThemeUploadError] = useState<string | null>(null)
|
||||||
|
const [themeFilename, setThemeFilename] = useState('')
|
||||||
|
const [themeContent, setThemeContent] = useState('')
|
||||||
|
const [themeLinkUrl, setThemeLinkUrl] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
|
setMaxLogsInput(panel.settings?.max_logs != null ? String(panel.settings.max_logs) : '')
|
||||||
@@ -180,28 +185,178 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
setContent('')
|
setContent('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadBuiltinTheme(name: string) {
|
||||||
|
const vars = BUILTIN_THEMES[name]
|
||||||
|
if (!vars) return
|
||||||
|
downloadThemeFile(`${name}.css`, generateThemeCss(vars))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadThemeEntry(entry: CssEntry) {
|
||||||
|
if (entry.kind === 'imported') {
|
||||||
|
const res = await fetch(cssContentUrl(entry.id))
|
||||||
|
const text = await res.text()
|
||||||
|
downloadThemeFile(entry.filename ?? `${entry.id}.css`, text)
|
||||||
|
} else if (entry.path_or_url) {
|
||||||
|
window.open(entry.path_or_url, '_blank', 'noopener')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThemeFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
setThemeFilename(file.name)
|
||||||
|
setThemeContent(await file.text())
|
||||||
|
setThemeUploadError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThemeUpload(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setThemeUploadError(null)
|
||||||
|
if (!themeFilename.trim() || !themeContent.trim()) return
|
||||||
|
const { missing } = parseThemeCss(themeContent)
|
||||||
|
if (missing.length > 0) {
|
||||||
|
setThemeUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await panel.importThemeEntry(themeFilename.trim(), themeContent)
|
||||||
|
setThemeFilename('')
|
||||||
|
setThemeContent('')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleThemeLink(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!themeLinkUrl.trim()) return
|
||||||
|
await panel.linkThemeEntry(themeLinkUrl.trim())
|
||||||
|
setThemeLinkUrl('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeEntries = panel.cssEntries.filter((entry) => entry.is_theme)
|
||||||
|
const arbitraryCssEntries = panel.cssEntries.filter((entry) => !entry.is_theme)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
<div className="flex flex-col gap-5 text-sm text-text-secondary">
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Colour scheme</h3>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{COLOR_SCHEMES.map((scheme) => (
|
{COLOR_SCHEMES.map((scheme) => (
|
||||||
|
<div key={scheme} className="flex items-center">
|
||||||
<button
|
<button
|
||||||
key={scheme}
|
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => panel.setColorScheme(scheme)}
|
onClick={() => panel.setColorScheme(scheme)}
|
||||||
className={`rounded border px-2 py-1 text-xs capitalize ${
|
className={`rounded-l border px-2 py-1 text-xs capitalize ${
|
||||||
panel.settings?.color_scheme === scheme
|
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
|
||||||
? 'border-border-strong bg-surface-2 text-text'
|
? 'border-border-strong bg-surface-2 text-text'
|
||||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{scheme}
|
{scheme}
|
||||||
</button>
|
</button>
|
||||||
|
{scheme !== 'system' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadBuiltinTheme(scheme)}
|
||||||
|
className="rounded-r border border-l-0 border-border-soft px-1.5 py-1 text-xs text-text-faint hover:bg-surface-2 hover:text-text"
|
||||||
|
aria-label={`Download ${scheme} theme`}
|
||||||
|
title="Download as .css"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Custom themes</h3>
|
||||||
|
{themeEntries.length === 0 ? (
|
||||||
|
<div className="mb-2 text-xs text-text-faint">No custom themes yet.</div>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-2 flex flex-col gap-1">
|
||||||
|
{themeEntries.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.setCustomTheme(entry.id)}
|
||||||
|
className={`min-w-0 flex-1 truncate text-left capitalize ${
|
||||||
|
panel.activeCustomTheme?.id === entry.id ? 'text-text' : 'text-text-muted hover:text-text'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{entry.filename ?? entry.path_or_url}
|
||||||
|
</button>
|
||||||
|
<div className="ml-2 flex shrink-0 items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadThemeEntry(entry)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-text"
|
||||||
|
aria-label={`Download ${entry.filename ?? entry.path_or_url}`}
|
||||||
|
title="Download as .css"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
{entry.kind === 'imported' ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => panel.removeCssEntry(entry.id)}
|
||||||
|
className="rounded px-1.5 text-text-subtle hover:bg-surface-2 hover:text-danger-text"
|
||||||
|
aria-label={`Delete ${entry.filename}`}
|
||||||
|
>
|
||||||
|
delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-text-faintest" title="Linked CSS isn't deletable here">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleThemeUpload} className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".css,text/css"
|
||||||
|
onChange={handleThemeFileChange}
|
||||||
|
className="text-xs text-text-muted file:mr-2 file:rounded file:border file:border-border-soft file:bg-surface-2 file:px-2 file:py-1 file:text-text"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="theme name.css"
|
||||||
|
value={themeFilename}
|
||||||
|
onChange={(e) => setThemeFilename(e.target.value)}
|
||||||
|
className="rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
|
/>
|
||||||
|
{themeUploadError && <div className="text-xs text-danger-text-strong">{themeUploadError}</div>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Upload theme
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form onSubmit={handleThemeLink} className="mt-2 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="https://example.com/theme.css"
|
||||||
|
value={themeLinkUrl}
|
||||||
|
onChange={(e) => setThemeLinkUrl(e.target.value)}
|
||||||
|
className="min-w-0 flex-1 rounded border border-border-soft bg-surface-1 px-2 py-1 text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
|
||||||
|
>
|
||||||
|
Link theme
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Log retention</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -252,11 +407,11 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
|
<h3 className="mb-2 text-xs font-semibold uppercase tracking-wide text-text-subtle">Configured CSS</h3>
|
||||||
{panel.cssEntries.length === 0 ? (
|
{arbitraryCssEntries.length === 0 ? (
|
||||||
<div className="text-xs text-text-faint">No CSS configured.</div>
|
<div className="text-xs text-text-faint">No CSS configured.</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="flex flex-col gap-1">
|
<ul className="flex flex-col gap-1">
|
||||||
{panel.cssEntries.map((entry) => (
|
{arbitraryCssEntries.map((entry) => (
|
||||||
<li
|
<li
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
|
||||||
|
|||||||
+13
-2
@@ -103,6 +103,8 @@ export interface Settings {
|
|||||||
active_profile: string | null
|
active_profile: string | null
|
||||||
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
/** Maximum ingested logs kept in memory; `null` means unlimited. */
|
||||||
max_logs: number | null
|
max_logs: number | null
|
||||||
|
/** Id of the selected custom theme entry (ticket 033), or `null` if `color_scheme` alone applies. */
|
||||||
|
custom_theme_id: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CssEntryKind = 'imported' | 'linked'
|
export type CssEntryKind = 'imported' | 'linked'
|
||||||
@@ -112,6 +114,8 @@ export interface CssEntry {
|
|||||||
kind: CssEntryKind
|
kind: CssEntryKind
|
||||||
filename: string | null
|
filename: string | null
|
||||||
path_or_url: string | null
|
path_or_url: string | null
|
||||||
|
/** Whether this entry is a selectable colour-scheme theme (ticket 033) rather than arbitrary CSS. */
|
||||||
|
is_theme: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfileSummary {
|
export interface ProfileSummary {
|
||||||
@@ -304,9 +308,12 @@ export function updateSettings(update: {
|
|||||||
colorScheme?: ColorScheme
|
colorScheme?: ColorScheme
|
||||||
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
/** Omit to leave unchanged; `null` clears the cap back to unlimited. */
|
||||||
maxLogs?: number | null
|
maxLogs?: number | null
|
||||||
|
/** Omit to leave unchanged; `null` clears the selection (falls back to `colorScheme`). */
|
||||||
|
customThemeId?: string | null
|
||||||
}): Promise<Settings> {
|
}): Promise<Settings> {
|
||||||
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
const body: Record<string, unknown> = { color_scheme: update.colorScheme }
|
||||||
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
if ('maxLogs' in update) body.max_logs = update.maxLogs
|
||||||
|
if ('customThemeId' in update) body.custom_theme_id = update.customThemeId
|
||||||
return sendJson('/api/settings', 'PATCH', body)
|
return sendJson('/api/settings', 'PATCH', body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,8 +321,12 @@ export function fetchCssEntries(): Promise<CssEntry[]> {
|
|||||||
return getJson('/api/settings/css')
|
return getJson('/api/settings/css')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function importCss(filename: string, content: string): Promise<CssEntry> {
|
export function importCss(filename: string, content: string, isTheme = false): Promise<CssEntry> {
|
||||||
return sendJson('/api/settings/css', 'POST', { filename, content })
|
return sendJson('/api/settings/css', 'POST', { filename, content, is_theme: isTheme })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkCss(pathOrUrl: string, isTheme = false): Promise<CssEntry> {
|
||||||
|
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl, is_theme: isTheme })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteCssEntry(id: string): Promise<void> {
|
export function deleteCssEntry(id: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Downloadable/re-uploadable custom themes (ticket 033).
|
||||||
|
*
|
||||||
|
* A theme file is a small CSS file containing exactly one `:root { --c-*:
|
||||||
|
* ...; }` block defining every variable in `THEME_CSS_VARS` (the full set
|
||||||
|
* `index.css`'s `:root`/`html.<scheme>` blocks use to skin the app). Using
|
||||||
|
* `:root` (rather than a scoped selector) keeps the file trivially portable:
|
||||||
|
* the same content works whether it's applied at `:root` or scoped, and
|
||||||
|
* round-tripping a downloaded file back through upload reproduces it
|
||||||
|
* byte-for-byte-equivalent (same variable values).
|
||||||
|
*
|
||||||
|
* Applying a selected custom theme is left to `useSettingsPanel`, which
|
||||||
|
* injects the entry's CSS as an unscoped stylesheet *only* while no built-in
|
||||||
|
* `html.<scheme>` class is present — since both forms target `:root` with
|
||||||
|
* equal specificity, the later stylesheet in the cascade wins, so no
|
||||||
|
* rewriting of the file's own `:root` selector is needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Canonical list of every `--c-*` variable every theme in `index.css` defines. */
|
||||||
|
export const THEME_CSS_VARS = [
|
||||||
|
'--c-surface-0',
|
||||||
|
'--c-surface-1',
|
||||||
|
'--c-surface-2',
|
||||||
|
'--c-border',
|
||||||
|
'--c-border-soft',
|
||||||
|
'--c-border-faint',
|
||||||
|
'--c-border-strong',
|
||||||
|
'--c-text',
|
||||||
|
'--c-text-secondary',
|
||||||
|
'--c-text-muted',
|
||||||
|
'--c-text-subtle',
|
||||||
|
'--c-text-faint',
|
||||||
|
'--c-text-faintest',
|
||||||
|
'--c-danger-bg',
|
||||||
|
'--c-danger-border',
|
||||||
|
'--c-danger-text',
|
||||||
|
'--c-danger-text-strong',
|
||||||
|
'--c-accent',
|
||||||
|
'--c-accent-soft',
|
||||||
|
'--c-accent-text',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Built-in themes' fully-resolved variable values, matching `index.css`'s
|
||||||
|
* current cascade (a theme that doesn't override `--c-danger-*` there falls
|
||||||
|
* back to `:root`'s dark values there, so those are inlined here too — a
|
||||||
|
* downloaded built-in file is always complete, never relies on a `:root`
|
||||||
|
* fallback the download mechanism doesn't have access to).
|
||||||
|
*
|
||||||
|
* Keep in sync with `webapp/src/index.css` by hand; there's no built step
|
||||||
|
* that derives one from the other.
|
||||||
|
*/
|
||||||
|
export const BUILTIN_THEMES: Record<string, ThemeVars> = {
|
||||||
|
dark: {
|
||||||
|
'--c-surface-0': '#0a0a0a',
|
||||||
|
'--c-surface-1': '#171717',
|
||||||
|
'--c-surface-2': '#262626',
|
||||||
|
'--c-border': '#262626',
|
||||||
|
'--c-border-soft': '#404040',
|
||||||
|
'--c-border-faint': '#171717',
|
||||||
|
'--c-border-strong': '#a3a3a3',
|
||||||
|
'--c-text': '#f5f5f5',
|
||||||
|
'--c-text-secondary': '#d4d4d4',
|
||||||
|
'--c-text-muted': '#a3a3a3',
|
||||||
|
'--c-text-subtle': '#737373',
|
||||||
|
'--c-text-faint': '#525252',
|
||||||
|
'--c-text-faintest': '#404040',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#0ea5e9',
|
||||||
|
'--c-accent-soft': '#0369a1',
|
||||||
|
'--c-accent-text': '#7dd3fc',
|
||||||
|
},
|
||||||
|
light: {
|
||||||
|
'--c-surface-0': '#fafafa',
|
||||||
|
'--c-surface-1': '#f5f5f5',
|
||||||
|
'--c-surface-2': '#e5e5e5',
|
||||||
|
'--c-border': '#e5e5e5',
|
||||||
|
'--c-border-soft': '#d4d4d4',
|
||||||
|
'--c-border-faint': '#f5f5f5',
|
||||||
|
'--c-border-strong': '#525252',
|
||||||
|
'--c-text': '#171717',
|
||||||
|
'--c-text-secondary': '#404040',
|
||||||
|
'--c-text-muted': '#525252',
|
||||||
|
'--c-text-subtle': '#737373',
|
||||||
|
'--c-text-faint': '#a3a3a3',
|
||||||
|
'--c-text-faintest': '#d4d4d4',
|
||||||
|
'--c-danger-bg': '#fee2e2',
|
||||||
|
'--c-danger-border': '#fca5a5',
|
||||||
|
'--c-danger-text': '#b91c1c',
|
||||||
|
'--c-danger-text-strong': '#991b1b',
|
||||||
|
'--c-accent': '#0284c7',
|
||||||
|
'--c-accent-soft': '#7dd3fc',
|
||||||
|
'--c-accent-text': '#0369a1',
|
||||||
|
},
|
||||||
|
grey: {
|
||||||
|
'--c-surface-0': '#0a0a0a',
|
||||||
|
'--c-surface-1': '#171717',
|
||||||
|
'--c-surface-2': '#262626',
|
||||||
|
'--c-border': '#262626',
|
||||||
|
'--c-border-soft': '#404040',
|
||||||
|
'--c-border-faint': '#171717',
|
||||||
|
'--c-border-strong': '#a3a3a3',
|
||||||
|
'--c-text': '#f5f5f5',
|
||||||
|
'--c-text-secondary': '#d4d4d4',
|
||||||
|
'--c-text-muted': '#a3a3a3',
|
||||||
|
'--c-text-subtle': '#737373',
|
||||||
|
'--c-text-faint': '#525252',
|
||||||
|
'--c-text-faintest': '#404040',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#38bdf8',
|
||||||
|
'--c-accent-soft': '#0369a1',
|
||||||
|
'--c-accent-text': '#7dd3fc',
|
||||||
|
},
|
||||||
|
red: {
|
||||||
|
'--c-surface-0': '#282828',
|
||||||
|
'--c-surface-1': '#4e4e4e',
|
||||||
|
'--c-surface-2': '#5a5a5a',
|
||||||
|
'--c-border': '#5a5a5a',
|
||||||
|
'--c-border-soft': '#6a6a6a',
|
||||||
|
'--c-border-faint': '#4e4e4e',
|
||||||
|
'--c-border-strong': '#b4b4b4',
|
||||||
|
'--c-text': '#f0f0f0',
|
||||||
|
'--c-text-secondary': '#d0d0d0',
|
||||||
|
'--c-text-muted': '#b4b4b4',
|
||||||
|
'--c-text-subtle': '#9a9a9a',
|
||||||
|
'--c-text-faint': '#818181',
|
||||||
|
'--c-text-faintest': '#6a6a6a',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#f87171',
|
||||||
|
'--c-accent-soft': '#b91c1c',
|
||||||
|
'--c-accent-text': '#fca5a5',
|
||||||
|
},
|
||||||
|
green: {
|
||||||
|
'--c-surface-0': '#1a1a1a',
|
||||||
|
'--c-surface-1': '#343434',
|
||||||
|
'--c-surface-2': '#3d3d3d',
|
||||||
|
'--c-border': '#3d3d3d',
|
||||||
|
'--c-border-soft': '#4a4a4a',
|
||||||
|
'--c-border-faint': '#343434',
|
||||||
|
'--c-border-strong': '#949494',
|
||||||
|
'--c-text': '#ececec',
|
||||||
|
'--c-text-secondary': '#bbbbbb',
|
||||||
|
'--c-text-muted': '#949494',
|
||||||
|
'--c-text-subtle': '#747474',
|
||||||
|
'--c-text-faint': '#5c5c5c',
|
||||||
|
'--c-text-faintest': '#4a4a4a',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#4ade80',
|
||||||
|
'--c-accent-soft': '#15803d',
|
||||||
|
'--c-accent-text': '#86efac',
|
||||||
|
},
|
||||||
|
blue: {
|
||||||
|
'--c-surface-0': '#363636',
|
||||||
|
'--c-surface-1': '#545454',
|
||||||
|
'--c-surface-2': '#666666',
|
||||||
|
'--c-border': '#666666',
|
||||||
|
'--c-border-soft': '#7a7a7a',
|
||||||
|
'--c-border-faint': '#545454',
|
||||||
|
'--c-border-strong': '#adadad',
|
||||||
|
'--c-text': '#ececec',
|
||||||
|
'--c-text-secondary': '#c8c8c8',
|
||||||
|
'--c-text-muted': '#adadad',
|
||||||
|
'--c-text-subtle': '#989898',
|
||||||
|
'--c-text-faint': '#888888',
|
||||||
|
'--c-text-faintest': '#7a7a7a',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#60a5fa',
|
||||||
|
'--c-accent-soft': '#1d4ed8',
|
||||||
|
'--c-accent-text': '#93c5fd',
|
||||||
|
},
|
||||||
|
yellow: {
|
||||||
|
'--c-surface-0': '#242424',
|
||||||
|
'--c-surface-1': '#424242',
|
||||||
|
'--c-surface-2': '#4a4a4a',
|
||||||
|
'--c-border': '#4a4a4a',
|
||||||
|
'--c-border-soft': '#545454',
|
||||||
|
'--c-border-faint': '#424242',
|
||||||
|
'--c-border-strong': '#888888',
|
||||||
|
'--c-text': '#e0e0e0',
|
||||||
|
'--c-text-secondary': '#a2a2a2',
|
||||||
|
'--c-text-muted': '#888888',
|
||||||
|
'--c-text-subtle': '#797979',
|
||||||
|
'--c-text-faint': '#676767',
|
||||||
|
'--c-text-faintest': '#545454',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#facc15',
|
||||||
|
'--c-accent-soft': '#a16207',
|
||||||
|
'--c-accent-text': '#fde047',
|
||||||
|
},
|
||||||
|
pink: {
|
||||||
|
'--c-surface-0': '#2c2c2c',
|
||||||
|
'--c-surface-1': '#4e4e4e',
|
||||||
|
'--c-surface-2': '#5a5a5a',
|
||||||
|
'--c-border': '#5a5a5a',
|
||||||
|
'--c-border-soft': '#6b6b6b',
|
||||||
|
'--c-border-faint': '#4e4e4e',
|
||||||
|
'--c-border-strong': '#b3b3b3',
|
||||||
|
'--c-text': '#f2f2f2',
|
||||||
|
'--c-text-secondary': '#d0d0d0',
|
||||||
|
'--c-text-muted': '#b3b3b3',
|
||||||
|
'--c-text-subtle': '#9a9a9a',
|
||||||
|
'--c-text-faint': '#818181',
|
||||||
|
'--c-text-faintest': '#6b6b6b',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#f472b6',
|
||||||
|
'--c-accent-soft': '#be185d',
|
||||||
|
'--c-accent-text': '#f9a8d4',
|
||||||
|
},
|
||||||
|
orange: {
|
||||||
|
'--c-surface-0': '#252525',
|
||||||
|
'--c-surface-1': '#474747',
|
||||||
|
'--c-surface-2': '#565656',
|
||||||
|
'--c-border': '#565656',
|
||||||
|
'--c-border-soft': '#676767',
|
||||||
|
'--c-border-faint': '#474747',
|
||||||
|
'--c-border-strong': '#9b9b9b',
|
||||||
|
'--c-text': '#eaeaea',
|
||||||
|
'--c-text-secondary': '#b8b8b8',
|
||||||
|
'--c-text-muted': '#9b9b9b',
|
||||||
|
'--c-text-subtle': '#888888',
|
||||||
|
'--c-text-faint': '#7b7b7b',
|
||||||
|
'--c-text-faintest': '#676767',
|
||||||
|
'--c-danger-bg': '#450a0a',
|
||||||
|
'--c-danger-border': '#7f1d1d',
|
||||||
|
'--c-danger-text': '#f87171',
|
||||||
|
'--c-danger-text-strong': '#fca5a5',
|
||||||
|
'--c-accent': '#fb923c',
|
||||||
|
'--c-accent-soft': '#c2410c',
|
||||||
|
'--c-accent-text': '#fdba74',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a downloadable theme CSS file's content from a full variable map. */
|
||||||
|
export function generateThemeCss(vars: ThemeVars): string {
|
||||||
|
const lines = THEME_CSS_VARS.map((name) => ` ${name}: ${vars[name]};`)
|
||||||
|
return `:root {\n${lines.join('\n')}\n}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTheme {
|
||||||
|
vars: ThemeVars
|
||||||
|
missing: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a theme CSS file's content, pulling `--c-*: value;` declarations
|
||||||
|
* out of its first `:root { ... }` block (or, failing that, out of the
|
||||||
|
* whole file — tolerant of a hand-edited file that dropped the selector).
|
||||||
|
* Returns every recognized variable found plus the list of canonical
|
||||||
|
* variables (see `THEME_CSS_VARS`) that were missing, so callers can reject
|
||||||
|
* an incomplete upload with a clear error.
|
||||||
|
*/
|
||||||
|
export function parseThemeCss(content: string): ParsedTheme {
|
||||||
|
const rootMatch = content.match(/:root\s*\{([^}]*)\}/)
|
||||||
|
const body = rootMatch ? rootMatch[1] : content
|
||||||
|
const found: Record<string, string> = {}
|
||||||
|
const declRegex = /(--c-[a-z0-9-]+)\s*:\s*([^;]+);/gi
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = declRegex.exec(body))) {
|
||||||
|
found[match[1]] = match[2].trim()
|
||||||
|
}
|
||||||
|
const missing = THEME_CSS_VARS.filter((name) => !(name in found))
|
||||||
|
const vars = Object.fromEntries(THEME_CSS_VARS.map((name) => [name, found[name] ?? ''])) as ThemeVars
|
||||||
|
return { vars, missing }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triggers a browser download of `content` as `filename` (a `.css` file). */
|
||||||
|
export function downloadThemeFile(filename: string, content: string) {
|
||||||
|
const blob = new Blob([content], { type: 'text/css' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
link.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
fetchProfiles,
|
fetchProfiles,
|
||||||
fetchSettings,
|
fetchSettings,
|
||||||
importCss,
|
importCss,
|
||||||
|
linkCss,
|
||||||
updateSettings,
|
updateSettings,
|
||||||
type ColorScheme,
|
type ColorScheme,
|
||||||
type CssEntry,
|
type CssEntry,
|
||||||
@@ -19,6 +20,9 @@ import {
|
|||||||
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
/** `data-*` marker used to find/remove previously-injected CSS tags on re-apply. */
|
||||||
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
const INJECTED_CSS_ATTR = 'data-ottyl-css-id'
|
||||||
|
|
||||||
|
/** `data-*` marker for the single injected `<link>` for the active custom theme, if any (ticket 033). */
|
||||||
|
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id'
|
||||||
|
|
||||||
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
|
/** Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. */
|
||||||
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow', 'pink', 'orange'] as const
|
||||||
|
|
||||||
@@ -29,12 +33,22 @@ const THEME_CLASSES = ['light', 'dark', 'grey', 'red', 'green', 'blue', 'yellow'
|
|||||||
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
|
* `data-color-scheme` attribute. Every visual role the app uses is a CSS
|
||||||
* custom property keyed off that class (see `index.css`), so switching it
|
* custom property keyed off that class (see `index.css`), so switching it
|
||||||
* re-skins the whole app, not just the base page background.
|
* re-skins the whole app, not just the base page background.
|
||||||
|
*
|
||||||
|
* `customThemeActive` (ticket 033): when a custom theme is selected, no
|
||||||
|
* built-in `html.<scheme>` class is added at all — its own injected `:root`
|
||||||
|
* override (see `applyCustomThemeCss`) is the only thing setting the
|
||||||
|
* `--c-*` variables, and adding a built-in class alongside it would win the
|
||||||
|
* cascade (a class selector is more specific than `:root`) and mask it.
|
||||||
*/
|
*/
|
||||||
function applyColorScheme(colorScheme: ColorScheme) {
|
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
|
root.classList.remove(...THEME_CLASSES)
|
||||||
|
if (customThemeActive) {
|
||||||
|
root.dataset.colorScheme = 'custom'
|
||||||
|
return
|
||||||
|
}
|
||||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
|
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? true
|
||||||
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
|
const effective = colorScheme === 'system' ? (prefersDark ? 'dark' : 'light') : colorScheme
|
||||||
root.classList.remove(...THEME_CLASSES)
|
|
||||||
root.classList.add(effective)
|
root.classList.add(effective)
|
||||||
root.dataset.colorScheme = colorScheme
|
root.dataset.colorScheme = colorScheme
|
||||||
}
|
}
|
||||||
@@ -45,6 +59,10 @@ function applyColorScheme(colorScheme: ColorScheme) {
|
|||||||
* their `path_or_url`), after removing any tags this hook previously
|
* their `path_or_url`), after removing any tags this hook previously
|
||||||
* injected — so the rendered app reflects the current entry list immediately
|
* injected — so the rendered app reflects the current entry list immediately
|
||||||
* on every change, without a page reload.
|
* on every change, without a page reload.
|
||||||
|
*
|
||||||
|
* Only arbitrary (non-theme) entries are applied unconditionally this way;
|
||||||
|
* theme entries (`is_theme: true`) are applied separately and only while
|
||||||
|
* selected, see `applyCustomThemeCss`.
|
||||||
*/
|
*/
|
||||||
function applyCssEntries(entries: CssEntry[]) {
|
function applyCssEntries(entries: CssEntry[]) {
|
||||||
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
document.querySelectorAll(`link[${INJECTED_CSS_ATTR}]`).forEach((el) => el.remove())
|
||||||
@@ -59,6 +77,25 @@ function applyCssEntries(entries: CssEntry[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects the single active custom theme's CSS (ticket 033) as a
|
||||||
|
* `<link>` — same mechanism as `applyCssEntries`, kept separate since this
|
||||||
|
* one is conditional on `settings.custom_theme_id`, not always-on. `entry`
|
||||||
|
* is `null` when no custom theme is selected, which just removes any
|
||||||
|
* previously-injected tag.
|
||||||
|
*/
|
||||||
|
function applyCustomThemeCss(entry: CssEntry | null) {
|
||||||
|
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove())
|
||||||
|
if (!entry) return
|
||||||
|
const href = entry.kind === 'imported' ? cssContentUrl(entry.id) : entry.path_or_url
|
||||||
|
if (!href) return
|
||||||
|
const link = document.createElement('link')
|
||||||
|
link.rel = 'stylesheet'
|
||||||
|
link.href = href
|
||||||
|
link.setAttribute(INJECTED_THEME_ATTR, entry.id)
|
||||||
|
document.head.appendChild(link)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
* Owns fetching + mutating settings/CSS/profile state for the NavMenu panels
|
||||||
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
* (ticket 011), and applies the colour scheme / imported CSS to the document
|
||||||
@@ -95,14 +132,22 @@ export function useSettingsPanel() {
|
|||||||
refreshProfiles().catch(() => {})
|
refreshProfiles().catch(() => {})
|
||||||
}, [refreshSettings, refreshCss, refreshProfiles])
|
}, [refreshSettings, refreshCss, refreshProfiles])
|
||||||
|
|
||||||
useEffect(() => {
|
const activeCustomTheme = settings?.custom_theme_id
|
||||||
if (settings) applyColorScheme(settings.color_scheme)
|
? cssEntries.find((entry) => entry.is_theme && entry.id === settings.custom_theme_id) ?? null
|
||||||
}, [settings])
|
: null
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyCssEntries(cssEntries)
|
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null)
|
||||||
|
}, [settings, activeCustomTheme])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyCssEntries(cssEntries.filter((entry) => !entry.is_theme))
|
||||||
}, [cssEntries])
|
}, [cssEntries])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyCustomThemeCss(activeCustomTheme)
|
||||||
|
}, [activeCustomTheme])
|
||||||
|
|
||||||
const guarded = useCallback((action: () => Promise<void>) => {
|
const guarded = useCallback((action: () => Promise<void>) => {
|
||||||
return async () => {
|
return async () => {
|
||||||
try {
|
try {
|
||||||
@@ -117,7 +162,18 @@ export function useSettingsPanel() {
|
|||||||
const setColorScheme = useCallback(
|
const setColorScheme = useCallback(
|
||||||
(colorScheme: ColorScheme) =>
|
(colorScheme: ColorScheme) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
const next = await updateSettings({ colorScheme })
|
// Selecting a built-in scheme always deselects any custom theme —
|
||||||
|
// the two are mutually exclusive (see `applyColorScheme`).
|
||||||
|
const next = await updateSettings({ colorScheme, customThemeId: null })
|
||||||
|
setSettings(next)
|
||||||
|
})(),
|
||||||
|
[guarded],
|
||||||
|
)
|
||||||
|
|
||||||
|
const setCustomTheme = useCallback(
|
||||||
|
(id: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
const next = await updateSettings({ customThemeId: id })
|
||||||
setSettings(next)
|
setSettings(next)
|
||||||
})(),
|
})(),
|
||||||
[guarded],
|
[guarded],
|
||||||
@@ -141,13 +197,37 @@ export function useSettingsPanel() {
|
|||||||
[guarded, refreshCss],
|
[guarded, refreshCss],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const importThemeEntry = useCallback(
|
||||||
|
(filename: string, content: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await importCss(filename, content, true)
|
||||||
|
await refreshCss()
|
||||||
|
})(),
|
||||||
|
[guarded, refreshCss],
|
||||||
|
)
|
||||||
|
|
||||||
|
const linkThemeEntry = useCallback(
|
||||||
|
(pathOrUrl: string) =>
|
||||||
|
guarded(async () => {
|
||||||
|
await linkCss(pathOrUrl, true)
|
||||||
|
await refreshCss()
|
||||||
|
})(),
|
||||||
|
[guarded, refreshCss],
|
||||||
|
)
|
||||||
|
|
||||||
const removeCssEntry = useCallback(
|
const removeCssEntry = useCallback(
|
||||||
(id: string) =>
|
(id: string) =>
|
||||||
guarded(async () => {
|
guarded(async () => {
|
||||||
await deleteCssEntry(id)
|
await deleteCssEntry(id)
|
||||||
await refreshCss()
|
await refreshCss()
|
||||||
|
// The removed entry may have been the active custom theme — fall
|
||||||
|
// back to `color_scheme` rather than leaving a dangling selection.
|
||||||
|
if (settings?.custom_theme_id === id) {
|
||||||
|
const next = await updateSettings({ customThemeId: null })
|
||||||
|
setSettings(next)
|
||||||
|
}
|
||||||
})(),
|
})(),
|
||||||
[guarded, refreshCss],
|
[guarded, refreshCss, settings],
|
||||||
)
|
)
|
||||||
|
|
||||||
const addProfile = useCallback(
|
const addProfile = useCallback(
|
||||||
@@ -180,11 +260,15 @@ export function useSettingsPanel() {
|
|||||||
return {
|
return {
|
||||||
settings,
|
settings,
|
||||||
cssEntries,
|
cssEntries,
|
||||||
|
activeCustomTheme,
|
||||||
profiles,
|
profiles,
|
||||||
error,
|
error,
|
||||||
setColorScheme,
|
setColorScheme,
|
||||||
|
setCustomTheme,
|
||||||
setMaxLogs,
|
setMaxLogs,
|
||||||
importCssEntry,
|
importCssEntry,
|
||||||
|
importThemeEntry,
|
||||||
|
linkThemeEntry,
|
||||||
removeCssEntry,
|
removeCssEntry,
|
||||||
addProfile,
|
addProfile,
|
||||||
removeProfile,
|
removeProfile,
|
||||||
|
|||||||
Reference in New Issue
Block a user