diff --git a/SKILL.md b/SKILL.md index 019dcad..0302e41 100644 --- a/SKILL.md +++ b/SKILL.md @@ -124,12 +124,10 @@ instance's active profile. ### Settings & theming -- **`get_settings`** / **`update_settings`** — read/update color scheme, active profile, - `max_logs`, custom theme, ntfy topic/server. -- **`list_css`** — list imported/linked custom CSS theme entries. -- **`import_css`** — import a CSS theme by file content. -- **`link_css`** — link an external CSS file/URL as a theme instead of importing content. -- **`remove_css`** / **`get_css_content`** — remove a CSS entry / read its raw content by id. +- **`get_settings`** / **`update_settings`** — read/update the selected theme, active profile, + `max_logs`, ntfy topic/server. +- **`list_themes`** — list the names of the themes embedded in this build. +- **`get_theme_content`** — read a theme's raw CSS content by name. ### Alerts diff --git a/src/api/dto.rs b/src/api/dto.rs index 50a92a8..c999c10 100644 --- a/src/api/dto.rs +++ b/src/api/dto.rs @@ -2,7 +2,7 @@ use crate::engine::alerts::AlertCondition; use crate::engine::data_value::MetricPrimitiveKind; use crate::engine::metric_primitive::MetricPrimitive; use crate::config::profile::{ChartType, Dashboard, Panel, Profile}; -use crate::config::{ColorScheme, CssEntry, Settings}; +use crate::config::Settings; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -108,57 +108,11 @@ pub struct ErrorDto { pub error: String, } -#[derive(Debug, Deserialize)] -pub struct ImportCssRequest { - pub filename: String, - pub content: String, -} - -#[derive(Debug, Deserialize)] -pub struct LinkCssRequest { - pub path_or_url: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CssEntryDto { - pub id: String, - pub kind: CssEntryKind, - pub filename: Option, - pub path_or_url: Option, -} - -#[derive(Debug, Clone, Copy, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum CssEntryKind { - Imported, - Linked, -} - -impl From<&CssEntry> for CssEntryDto { - fn from(entry: &CssEntry) -> Self { - match entry { - CssEntry::Imported { id, filename } => CssEntryDto { - id: id.clone(), - kind: CssEntryKind::Imported, - filename: Some(filename.clone()), - path_or_url: None, - }, - CssEntry::Linked { path_or_url } => CssEntryDto { - id: crate::config::css_entry_id(entry), - kind: CssEntryKind::Linked, - filename: None, - path_or_url: Some(path_or_url.clone()), - }, - } - } -} - #[derive(Debug, Clone, Serialize)] pub struct SettingsDto { - pub color_scheme: ColorScheme, + pub theme: String, pub active_profile: Option, pub max_logs: Option, - pub custom_theme_id: Option, pub ntfy_topic: Option, pub ntfy_server: Option, } @@ -166,10 +120,9 @@ pub struct SettingsDto { impl From<&Settings> for SettingsDto { fn from(settings: &Settings) -> Self { SettingsDto { - color_scheme: settings.color_scheme, + theme: settings.theme.clone(), active_profile: settings.active_profile.clone(), max_logs: settings.max_logs, - custom_theme_id: settings.custom_theme_id.clone(), ntfy_topic: settings.ntfy_topic.clone(), ntfy_server: settings.ntfy_server.clone(), } @@ -178,12 +131,10 @@ impl From<&Settings> for SettingsDto { #[derive(Debug, Deserialize)] pub struct UpdateSettingsRequest { - pub color_scheme: Option, + pub theme: Option, #[serde(default, deserialize_with = "deserialize_double_option")] pub max_logs: Option>, #[serde(default, deserialize_with = "deserialize_double_option")] - pub custom_theme_id: Option>, - #[serde(default, deserialize_with = "deserialize_double_option")] pub ntfy_topic: Option>, #[serde(default, deserialize_with = "deserialize_double_option")] pub ntfy_server: Option>, diff --git a/src/api/mcp_server.rs b/src/api/mcp_server.rs index 06e7090..eac1eae 100644 --- a/src/api/mcp_server.rs +++ b/src/api/mcp_server.rs @@ -186,36 +186,23 @@ pub struct DeleteDashboardParams { pub dashboard_id: String, } -// ---- settings / css ---- +// ---- settings / theming ---- #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] pub struct UpdateSettingsParams { #[serde(default)] - pub color_scheme: Option, + pub theme: Option, #[serde(default)] pub max_logs: Option>, #[serde(default)] - pub custom_theme_id: Option>, - #[serde(default)] pub ntfy_topic: Option>, #[serde(default)] pub ntfy_server: Option>, } -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct ImportCssParams { - pub filename: String, - pub content: String, -} - -#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] -pub struct LinkCssParams { - pub path_or_url: String, -} - #[derive(Debug, Deserialize, schemars::JsonSchema)] -pub struct CssIdParams { - pub id: String, +pub struct GetThemeContentParams { + pub name: String, } // ---- alerts ---- @@ -734,7 +721,7 @@ impl OttylMcp { // ---- settings / theming ---- - #[tool(description = "Read the running instance's global settings (color scheme, active profile, max_logs, ntfy config).")] + #[tool(description = "Read the running instance's global settings (theme, active profile, max_logs, ntfy config).")] async fn get_settings(&self) -> Result { json_result(self.get_json("/api/settings", &[]).await?) } @@ -748,52 +735,19 @@ impl OttylMcp { json_result(self.send_json(reqwest::Method::PATCH, "/api/settings", Some(&body)).await?) } - #[tool(description = "List imported/linked custom CSS theme entries.")] - async fn list_css(&self) -> Result { - json_result(self.get_json("/api/settings/css", &[]).await?) + #[tool(description = "List the names of the themes embedded in this build (see /api/themes).")] + async fn list_themes(&self) -> Result { + json_result(self.get_json("/api/themes", &[]).await?) } - #[tool(description = "Import a custom CSS theme file by content.")] - async fn import_css( + #[tool(description = "Read the raw CSS content of a theme by name.")] + async fn get_theme_content( &self, - Parameters(params): Parameters, - ) -> Result { - let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; - json_result(self.send_json(reqwest::Method::POST, "/api/settings/css", Some(&body)).await?) - } - - #[tool(description = "Link an external CSS file or URL as a custom theme, instead of importing its content.")] - async fn link_css( - &self, - Parameters(params): Parameters, - ) -> Result { - let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?; - json_result(self.send_json(reqwest::Method::POST, "/api/settings/css/link", Some(&body)).await?) - } - - #[tool(description = "Remove a custom CSS entry by id.")] - async fn remove_css( - &self, - Parameters(params): Parameters, - ) -> Result { - json_result( - self.send_json( - reqwest::Method::DELETE, - &format!("/api/settings/css/{}", urlencoding_path(¶ms.id)), - None, - ) - .await?, - ) - } - - #[tool(description = "Read the raw content of an imported CSS entry by id.")] - async fn get_css_content( - &self, - Parameters(params): Parameters, + Parameters(params): Parameters, ) -> Result { let response = self .client - .get(self.url(&format!("/api/settings/css/{}/content", urlencoding_path(¶ms.id)))) + .get(self.url(&format!("/api/themes/{}", urlencoding_path(¶ms.name)))) .send() .await .map_err(|err| mcp_error("failed to reach ottyl", err))?; @@ -801,7 +755,7 @@ impl OttylMcp { let text = response .text() .await - .map_err(|err| mcp_error("failed to read css content response", err))?; + .map_err(|err| mcp_error("failed to read theme content response", err))?; if !status.is_success() { return Err(mcp_error("ottyl rejected the request", text)); } @@ -962,7 +916,7 @@ impl ServerHandler for OttylMcp { "Proxies to a running ottyl instance's HTTP API. Covers everything the web UI can do: \ logs/timeline/stats, param & metric declarations, PromQL queries, dashboards & panels \ (including render_panel/render_dashboard, which return a chart's underlying series data \ - as JSON so an agent can reason about it without a browser), settings & custom CSS themes, \ + as JSON so an agent can reason about it without a browser), settings & themes, \ alert rules, and profile management. See the embedded SKILL.md (GET /SKILL.md on the \ target instance) for examples." .to_string(), diff --git a/src/api/mod.rs b/src/api/mod.rs index 6d06d88..16f97c5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,12 +2,12 @@ pub mod dto; pub mod mcp_server; use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use crate::api::dto::{ BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest, - CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, + DashboardDto, ErrorDto, MetricDeclarationDto, ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, }; @@ -18,7 +18,7 @@ use crate::engine::{ }; use crate::engine::alerts::{deliver_ntfy, AlertError}; use crate::config::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError}; -use crate::config::{CssError, SettingsHandle}; +use crate::config::SettingsHandle; use axum::extract::{Path as AxumPath, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Json, Response}; @@ -29,6 +29,11 @@ use tokio::sync::oneshot; static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); +// Every file here is a selectable theme: a complete stylesheet (colors, spacing, fonts, ...) +// applied on top of the base app shell. Add a new theme by dropping another `.css` file in +// `themes/` and rebuilding — no code changes needed (see `webapp/src/index.css`). +static THEMES: EmbedDir = fs_embed::fs_embed!("themes"); + const SKILL_MD: &str = include_str!("../../SKILL.md"); #[derive(Clone)] @@ -39,13 +44,6 @@ struct ApiState { } impl ApiState { - fn css_dir(&self) -> PathBuf { - self.settings_path - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(".")) - } - fn persist_param_declaration(&self, request: &DeclareParamRequest) { let mut settings = self.settings.lock().unwrap(); let Some(profile_name) = settings.active_profile.clone() else { @@ -159,10 +157,8 @@ pub fn router(engine: EngineHandle, settings: SettingsHandle, settings_path: Pat ) .route("/api/query", get(run_promql_query)) .route("/api/settings", get(get_settings).patch(update_settings)) - .route("/api/settings/css", get(list_css).post(import_css)) - .route("/api/settings/css/link", post(link_css)) - .route("/api/settings/css/{id}", delete(remove_css)) - .route("/api/settings/css/{id}/content", get(get_css_content)) + .route("/api/themes", get(list_themes)) + .route("/api/themes/{name}", get(get_theme_content)) .route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule)) .route( "/api/settings/alerts/{id}", @@ -553,84 +549,27 @@ async fn run_promql_query( } } -impl CssError { - fn into_response(self) -> axum::response::Response { - match self { - CssError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(), - CssError::NotImported => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), - CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(), +async fn list_themes() -> impl IntoResponse { + let mut names: Vec = THEMES + .walk() + .filter_map(|file| file.file_name().map(|name| name.trim_end_matches(".css").to_string())) + .collect(); + names.sort(); + Json(names).into_response() +} + +async fn get_theme_content(AxumPath(name): AxumPath) -> impl IntoResponse { + // Themes are just filenames within a flat embedded dir, not filesystem paths, so reject + // anything that isn't a plain name to rule out path traversal. + if name.is_empty() || !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') { + return StatusCode::NOT_FOUND.into_response(); + } + match THEMES.get_file(&format!("{name}.css")) { + Some(file) => { + let bytes = file.read_bytes().unwrap_or_default(); + ([(header::CONTENT_TYPE, "text/css; charset=utf-8")], bytes).into_response() } - } -} - -async fn list_css(State(state): State) -> impl IntoResponse { - let settings = state.settings.lock().unwrap(); - let entries: Vec = settings.css_entries.iter().map(CssEntryDto::from).collect(); - Json(entries).into_response() -} - -async fn import_css( - State(state): State, - Json(body): Json, -) -> 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) { - Ok(id) => { - let entry = settings - .css_entries - .iter() - .find(|entry| crate::config::css_entry_id(entry) == id) - .expect("entry was just inserted by import_css"); - (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() - } - Err(err) => err.into_response(), - } -} - -async fn link_css( - State(state): State, - Json(body): Json, -) -> impl IntoResponse { - let mut settings = state.settings.lock().unwrap(); - match settings.link_css(&state.settings_path, body.path_or_url.clone()) { - Ok(()) => { - let entry = settings - .css_entries - .last() - .expect("entry was just pushed by link_css"); - (StatusCode::CREATED, Json(CssEntryDto::from(entry))).into_response() - } - Err(err) => err.into_response(), - } -} - -async fn remove_css( - State(state): State, - AxumPath(id): AxumPath, -) -> impl IntoResponse { - let css_dir = state.css_dir(); - let mut settings = state.settings.lock().unwrap(); - match settings.remove_css(&state.settings_path, &css_dir, &id) { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(err) => err.into_response(), - } -} - -async fn get_css_content( - State(state): State, - AxumPath(id): AxumPath, -) -> impl IntoResponse { - let css_dir = state.css_dir(); - let settings = state.settings.lock().unwrap(); - match settings.css_content(&css_dir, &id) { - Ok(content) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "text/css; charset=utf-8")], - content, - ) - .into_response(), - Err(err) => err.into_response(), + None => StatusCode::NOT_FOUND.into_response(), } } @@ -699,15 +638,12 @@ async fn update_settings( let max_logs_changed = body.max_logs.is_some(); let new_max_logs = { let mut settings = state.settings.lock().unwrap(); - if let Some(color_scheme) = body.color_scheme { - settings.color_scheme = color_scheme; + if let Some(theme) = body.theme { + settings.theme = theme; } 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; - } if let Some(ntfy_topic) = body.ntfy_topic { settings.ntfy_topic = ntfy_topic; } diff --git a/src/config/mod.rs b/src/config/mod.rs index c585401..74c0313 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,13 +1,9 @@ pub mod profile; -use std::collections::hash_map::DefaultHasher; -use std::fmt; use std::fs; -use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::{SystemTime, UNIX_EPOCH}; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; @@ -17,39 +13,10 @@ use crate::config::profile::Profile; pub type SettingsHandle = Arc>; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "lowercase")] -pub enum ColorScheme { - Light, - #[serde(alias = "grey")] - Dark, - Red, - Green, - Blue, - Yellow, - Pink, - Orange, - #[serde(rename = "light_green")] - LightGreen, - #[serde(rename = "light_red")] - LightRed, - #[serde(rename = "light_blue")] - LightBlue, - #[serde(rename = "light_yellow")] - LightYellow, - #[serde(rename = "light_pink")] - LightPink, - #[serde(rename = "light_orange")] - LightOrange, - #[default] - System, -} - -#[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 }, +/// Name of the selected theme file in `themes/` (see `THEMES` in `src/api/mod.rs`), or +/// `"system"` to follow the OS light/dark preference between the `dark`/`light` themes. +fn default_theme() -> String { + "system".to_string() } #[derive(Debug, Clone, Deserialize, Default)] @@ -76,11 +43,13 @@ impl Default for ProfilesOnDisk { #[derive(Debug, Clone, Deserialize)] struct SettingsOnDisk { - color_scheme: ColorScheme, #[serde(default)] - css_entries: Vec, + theme: Option, + // Pre-theme-directory settings stored the selection as `color_scheme` (a fixed enum); a + // separate `custom_theme_id` pointed at an uploaded/linked CSS entry (both removed) and can't + // be mapped onto a theme file, so it's left out here and just ignored by serde on load. #[serde(default)] - custom_theme_id: Option, + color_scheme: Option, #[serde(default)] profiles: ProfilesOnDisk, #[serde(default)] @@ -101,10 +70,15 @@ impl From for Settings { ProfilesOnDisk::Inline(profiles) => (profiles, None), ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile), }; + let theme = on_disk.theme.unwrap_or_else(|| { + match on_disk.color_scheme.as_deref() { + Some("grey") => "dark".to_string(), + Some(scheme) => scheme.to_string(), + None => default_theme(), + } + }); Settings { - color_scheme: on_disk.color_scheme, - css_entries: on_disk.css_entries, - custom_theme_id: on_disk.custom_theme_id, + theme, profiles, active_profile: on_disk.active_profile.or(legacy_active_profile), max_logs: on_disk.max_logs, @@ -115,13 +89,11 @@ impl From for Settings { } } -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(from = "SettingsOnDisk")] pub struct Settings { - pub color_scheme: ColorScheme, - pub css_entries: Vec, - #[serde(default)] - pub custom_theme_id: Option, + #[serde(default = "default_theme")] + pub theme: String, pub profiles: Vec, pub active_profile: Option, #[serde(default)] @@ -134,6 +106,20 @@ pub struct Settings { pub ntfy_server: Option, } +impl Default for Settings { + fn default() -> Self { + Settings { + theme: default_theme(), + profiles: Vec::new(), + active_profile: None, + max_logs: None, + alert_rules: Vec::new(), + ntfy_topic: None, + ntfy_server: None, + } + } +} + pub fn default_settings_path() -> Option { ProjectDirs::from( crate::consts::APP_QUALIFIER, @@ -231,119 +217,6 @@ impl Settings { } } -#[derive(Debug)] -pub enum CssError { - NotFound, - NotImported, - Io(io::Error), -} - -impl fmt::Display for CssError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - CssError::NotFound => write!(f, "no CSS entry with that id"), - CssError::NotImported => write!(f, "CSS entry is linked, not imported"), - CssError::Io(err) => write!(f, "I/O error: {err}"), - } - } -} - -impl std::error::Error for CssError {} - -impl From for CssError { - fn from(err: io::Error) -> Self { - CssError::Io(err) - } -} - -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), - } -} - -fn linked_entry_id(path_or_url: &str) -> String { - let mut hasher = DefaultHasher::new(); - path_or_url.hash(&mut hasher); - format!("linked-{:016x}", hasher.finish()) -} - -fn generate_imported_css_id(filename: &str, content: &str) -> String { - let mut hasher = DefaultHasher::new(); - filename.hash(&mut hasher); - content.hash(&mut hasher); - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - nanos.hash(&mut hasher); - format!("{:016x}", hasher.finish()) -} - -fn imported_css_file_path(css_dir: &Path, id: &str) -> PathBuf { - css_dir.join(format!("{id}.css")) -} - -impl Settings { - pub fn import_css( - &mut self, - settings_path: &Path, - css_dir: &Path, - filename: String, - content: &str, - ) -> Result { - fs::create_dir_all(css_dir)?; - let id = generate_imported_css_id(&filename, content); - fs::write(imported_css_file_path(css_dir, &id), content)?; - self.css_entries.push(CssEntry::Imported { - id: id.clone(), - filename, - }); - self.save(settings_path)?; - Ok(id) - } - - pub fn link_css(&mut self, settings_path: &Path, path_or_url: String) -> Result<(), CssError> { - self.css_entries.push(CssEntry::Linked { path_or_url }); - self.save(settings_path)?; - Ok(()) - } - - pub fn remove_css( - &mut self, - settings_path: &Path, - css_dir: &Path, - id: &str, - ) -> Result<(), CssError> { - let index = self - .css_entries - .iter() - .position(|entry| css_entry_id(entry) == id); - match index.map(|i| &self.css_entries[i]) { - None => Err(CssError::NotFound), - Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), - Some(CssEntry::Imported { .. }) => { - let idx = index.unwrap(); - self.css_entries.remove(idx); - let _ = fs::remove_file(imported_css_file_path(css_dir, id)); - self.save(settings_path)?; - Ok(()) - } - } - } - - pub fn css_content(&self, css_dir: &Path, id: &str) -> Result { - match self.css_entries.iter().find(|entry| css_entry_id(entry) == id) { - None => Err(CssError::NotFound), - Some(CssEntry::Linked { .. }) => Err(CssError::NotImported), - Some(CssEntry::Imported { .. }) => { - Ok(fs::read_to_string(imported_css_file_path(css_dir, id))?) - } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -371,29 +244,12 @@ mod tests { } #[test] - fn light_variant_color_schemes_round_trip_through_serde() { - for (scheme, rename) in [ - (ColorScheme::LightGreen, "light_green"), - (ColorScheme::LightRed, "light_red"), - (ColorScheme::LightBlue, "light_blue"), - (ColorScheme::LightYellow, "light_yellow"), - (ColorScheme::LightPink, "light_pink"), - (ColorScheme::LightOrange, "light_orange"), - ] { - let json = serde_json::to_string(&scheme).unwrap(); - assert_eq!(json, format!("\"{rename}\"")); - let deserialized: ColorScheme = serde_json::from_str(&json).unwrap(); - assert_eq!(deserialized, scheme); - } - } - - #[test] - fn legacy_grey_color_scheme_deserializes_as_dark() { + fn legacy_grey_color_scheme_migrates_to_dark_theme() { let settings: Settings = serde_json::from_str( r#"{"color_scheme":"grey","css_entries":[],"custom_theme_id":null,"profiles":{"names":[],"active_profile":null,"profiles_dir":"profiles"},"max_logs":null,"alert_rules":[],"ntfy_topic":null,"ntfy_server":null}"#, ) .unwrap(); - assert_eq!(settings.color_scheme, ColorScheme::Dark); + assert_eq!(settings.theme, "dark"); } #[test] @@ -411,15 +267,10 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("settings.json"); - let mut settings = Settings::default(); - settings.color_scheme = ColorScheme::Dark; - settings.css_entries.push(CssEntry::Imported { - id: "abc123".to_string(), - filename: "theme.css".to_string(), - }); - settings.css_entries.push(CssEntry::Linked { - path_or_url: "https://example.com/style.css".to_string(), - }); + let mut settings = Settings { + theme: "dark".to_string(), + ..Settings::default() + }; settings.profiles.push(crate::config::profile::Profile::new("default".to_string())); settings.active_profile = Some("default".to_string()); settings.alert_rules.push(crate::engine::alerts::AlertRule { @@ -449,105 +300,6 @@ mod tests { assert!(path.exists()); } - #[test] - fn import_css_persists_file_and_entry_across_reload() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let css_dir = dir.path().to_path_buf(); - - let mut settings = Settings::load(&settings_path); - let id = settings - .import_css( - &settings_path, - &css_dir, - "theme.css".to_string(), - "body { color: red; }", - ) - .unwrap(); - - let css_file = css_dir.join(format!("{id}.css")); - assert!(css_file.exists()); - assert_eq!(fs::read_to_string(&css_file).unwrap(), "body { color: red; }"); - - drop(settings); - 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" - ))); - assert!(css_file.exists()); - assert_eq!( - reloaded.css_content(&css_dir, &id).unwrap(), - "body { color: red; }" - ); - } - - #[test] - fn remove_css_deletes_file_and_entry() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let css_dir = dir.path().to_path_buf(); - - let mut settings = Settings::load(&settings_path); - let id = settings - .import_css(&settings_path, &css_dir, "a.css".to_string(), "a{}") - .unwrap(); - let css_file = css_dir.join(format!("{id}.css")); - assert!(css_file.exists()); - - settings.remove_css(&settings_path, &css_dir, &id).unwrap(); - - assert!(!css_file.exists()); - assert!(!settings.css_entries.iter().any(|e| css_entry_id(e) == id)); - - let reloaded = Settings::load(&settings_path); - assert!(!reloaded.css_entries.iter().any(|e| css_entry_id(e) == id)); - } - - #[test] - fn remove_css_rejects_linked_entry() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let css_dir = dir.path().to_path_buf(); - - let mut settings = Settings::load(&settings_path); - settings - .link_css(&settings_path, "https://example.com/style.css".to_string()) - .unwrap(); - let linked_id = css_entry_id(&settings.css_entries[0]); - - let result = settings.remove_css(&settings_path, &css_dir, &linked_id); - assert!(matches!(result, Err(CssError::NotImported))); - assert_eq!(settings.css_entries.len(), 1); - } - - #[test] - fn remove_css_rejects_unknown_id() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let css_dir = dir.path().to_path_buf(); - - let mut settings = Settings::load(&settings_path); - let result = settings.remove_css(&settings_path, &css_dir, "does-not-exist"); - assert!(matches!(result, Err(CssError::NotFound))); - } - - #[test] - fn css_content_rejects_linked_entry() { - let dir = tempfile::tempdir().unwrap(); - let settings_path = dir.path().join("settings.json"); - let css_dir = dir.path().to_path_buf(); - - let mut settings = Settings::load(&settings_path); - settings - .link_css(&settings_path, "https://example.com/style.css".to_string()) - .unwrap(); - let linked_id = css_entry_id(&settings.css_entries[0]); - - let result = settings.css_content(&css_dir, &linked_id); - assert!(matches!(result, Err(CssError::NotImported))); - } - #[test] fn load_migrates_legacy_profiles_file_into_inline_settings() { let dir = tempfile::tempdir().unwrap(); diff --git a/themes/blue.css b/themes/blue.css new file mode 100644 index 0000000..6d92a4c --- /dev/null +++ b/themes/blue.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(210 32% 10%); + --c-surface-1: hsl(210 28% 16%); + --c-surface-2: hsl(210 26% 22%); + + --c-border: hsl(210 24% 24%); + --c-border-soft: hsl(210 22% 33%); + --c-border-faint: hsl(210 28% 16%); + --c-border-strong: hsl(210 15% 62%); + + --c-text: hsl(210 15% 94%); + --c-text-secondary: hsl(210 12% 82%); + --c-text-muted: hsl(210 10% 65%); + --c-text-subtle: hsl(210 8% 50%); + --c-text-faint: hsl(210 8% 38%); + --c-text-faintest: hsl(210 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(30 70% 62%); + --c-accent-soft: hsl(0 55% 34%); + --c-accent-text: hsl(30 75% 80%); + --c-accent-2: hsl(300 65% 68%); + --c-accent-3: hsl(120 65% 55%); +} diff --git a/themes/dark.css b/themes/dark.css new file mode 100644 index 0000000..13d324b --- /dev/null +++ b/themes/dark.css @@ -0,0 +1,28 @@ +:root { + --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; + --c-accent-2: #a78bfa; + --c-accent-3: #4ade80; +} diff --git a/themes/green.css b/themes/green.css new file mode 100644 index 0000000..34f8bc4 --- /dev/null +++ b/themes/green.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(140 32% 10%); + --c-surface-1: hsl(140 28% 16%); + --c-surface-2: hsl(140 26% 22%); + + --c-border: hsl(140 24% 24%); + --c-border-soft: hsl(140 22% 33%); + --c-border-faint: hsl(140 28% 16%); + --c-border-strong: hsl(140 15% 62%); + + --c-text: hsl(140 15% 94%); + --c-text-secondary: hsl(140 12% 82%); + --c-text-muted: hsl(140 10% 65%); + --c-text-subtle: hsl(140 8% 50%); + --c-text-faint: hsl(140 8% 38%); + --c-text-faintest: hsl(140 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(320 70% 62%); + --c-accent-soft: hsl(290 55% 34%); + --c-accent-text: hsl(320 75% 80%); + --c-accent-2: hsl(230 65% 62%); + --c-accent-3: hsl(50 65% 58%); +} diff --git a/themes/light.css b/themes/light.css new file mode 100644 index 0000000..fcdb767 --- /dev/null +++ b/themes/light.css @@ -0,0 +1,28 @@ +:root { + --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; + --c-accent-2: #7c3aed; + --c-accent-3: #16a34a; +} diff --git a/themes/light_blue.css b/themes/light_blue.css new file mode 100644 index 0000000..bb74162 --- /dev/null +++ b/themes/light_blue.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(210 40% 96%); + --c-surface-1: hsl(210 35% 92%); + --c-surface-2: hsl(210 30% 85%); + + --c-border: hsl(210 25% 80%); + --c-border-soft: hsl(210 22% 70%); + --c-border-faint: hsl(210 35% 92%); + --c-border-strong: hsl(210 20% 40%); + + --c-text: hsl(210 30% 12%); + --c-text-secondary: hsl(210 20% 25%); + --c-text-muted: hsl(210 15% 40%); + --c-text-subtle: hsl(210 10% 50%); + --c-text-faint: hsl(210 10% 65%); + --c-text-faintest: hsl(210 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(30 70% 40%); + --c-accent-soft: hsl(0 60% 85%); + --c-accent-text: hsl(30 75% 28%); + --c-accent-2: hsl(300 70% 45%); + --c-accent-3: hsl(120 70% 38%); +} diff --git a/themes/light_green.css b/themes/light_green.css new file mode 100644 index 0000000..a4e5f80 --- /dev/null +++ b/themes/light_green.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(140 40% 96%); + --c-surface-1: hsl(140 35% 92%); + --c-surface-2: hsl(140 30% 85%); + + --c-border: hsl(140 25% 80%); + --c-border-soft: hsl(140 22% 70%); + --c-border-faint: hsl(140 35% 92%); + --c-border-strong: hsl(140 20% 40%); + + --c-text: hsl(140 30% 12%); + --c-text-secondary: hsl(140 20% 25%); + --c-text-muted: hsl(140 15% 40%); + --c-text-subtle: hsl(140 10% 50%); + --c-text-faint: hsl(140 10% 65%); + --c-text-faintest: hsl(140 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(320 70% 40%); + --c-accent-soft: hsl(290 60% 85%); + --c-accent-text: hsl(320 75% 28%); + --c-accent-2: hsl(230 70% 45%); + --c-accent-3: hsl(50 70% 38%); +} diff --git a/themes/light_orange.css b/themes/light_orange.css new file mode 100644 index 0000000..ebe2de8 --- /dev/null +++ b/themes/light_orange.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(30 40% 96%); + --c-surface-1: hsl(30 35% 92%); + --c-surface-2: hsl(30 30% 85%); + + --c-border: hsl(30 25% 80%); + --c-border-soft: hsl(30 22% 70%); + --c-border-faint: hsl(30 35% 92%); + --c-border-strong: hsl(30 20% 40%); + + --c-text: hsl(30 30% 12%); + --c-text-secondary: hsl(30 20% 25%); + --c-text-muted: hsl(30 15% 40%); + --c-text-subtle: hsl(30 10% 50%); + --c-text-faint: hsl(30 10% 65%); + --c-text-faintest: hsl(30 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(210 70% 40%); + --c-accent-soft: hsl(180 60% 85%); + --c-accent-text: hsl(210 75% 28%); + --c-accent-2: hsl(120 70% 45%); + --c-accent-3: hsl(300 70% 38%); +} diff --git a/themes/light_pink.css b/themes/light_pink.css new file mode 100644 index 0000000..7ca16d6 --- /dev/null +++ b/themes/light_pink.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(330 40% 96%); + --c-surface-1: hsl(330 35% 92%); + --c-surface-2: hsl(330 30% 85%); + + --c-border: hsl(330 25% 80%); + --c-border-soft: hsl(330 22% 70%); + --c-border-faint: hsl(330 35% 92%); + --c-border-strong: hsl(330 20% 40%); + + --c-text: hsl(330 30% 12%); + --c-text-secondary: hsl(330 20% 25%); + --c-text-muted: hsl(330 15% 40%); + --c-text-subtle: hsl(330 10% 50%); + --c-text-faint: hsl(330 10% 65%); + --c-text-faintest: hsl(330 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(150 70% 40%); + --c-accent-soft: hsl(120 60% 85%); + --c-accent-text: hsl(150 75% 28%); + --c-accent-2: hsl(60 70% 45%); + --c-accent-3: hsl(240 70% 38%); +} diff --git a/themes/light_red.css b/themes/light_red.css new file mode 100644 index 0000000..f51e7a2 --- /dev/null +++ b/themes/light_red.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(0 40% 96%); + --c-surface-1: hsl(0 35% 92%); + --c-surface-2: hsl(0 30% 85%); + + --c-border: hsl(0 25% 80%); + --c-border-soft: hsl(0 22% 70%); + --c-border-faint: hsl(0 35% 92%); + --c-border-strong: hsl(0 20% 40%); + + --c-text: hsl(0 30% 12%); + --c-text-secondary: hsl(0 20% 25%); + --c-text-muted: hsl(0 15% 40%); + --c-text-subtle: hsl(0 10% 50%); + --c-text-faint: hsl(0 10% 65%); + --c-text-faintest: hsl(0 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(180 70% 40%); + --c-accent-soft: hsl(150 60% 85%); + --c-accent-text: hsl(180 75% 28%); + --c-accent-2: hsl(90 70% 45%); + --c-accent-3: hsl(270 70% 38%); +} diff --git a/themes/light_yellow.css b/themes/light_yellow.css new file mode 100644 index 0000000..8f21309 --- /dev/null +++ b/themes/light_yellow.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(50 40% 96%); + --c-surface-1: hsl(50 35% 92%); + --c-surface-2: hsl(50 30% 85%); + + --c-border: hsl(50 25% 80%); + --c-border-soft: hsl(50 22% 70%); + --c-border-faint: hsl(50 35% 92%); + --c-border-strong: hsl(50 20% 40%); + + --c-text: hsl(50 30% 12%); + --c-text-secondary: hsl(50 20% 25%); + --c-text-muted: hsl(50 15% 40%); + --c-text-subtle: hsl(50 10% 50%); + --c-text-faint: hsl(50 10% 65%); + --c-text-faintest: hsl(50 12% 80%); + + --c-danger-bg: #fee2e2; + --c-danger-border: #fca5a5; + --c-danger-text: #b91c1c; + --c-danger-text-strong: #991b1b; + + --c-accent: hsl(230 70% 40%); + --c-accent-soft: hsl(200 60% 85%); + --c-accent-text: hsl(230 75% 28%); + --c-accent-2: hsl(140 70% 45%); + --c-accent-3: hsl(320 70% 38%); +} diff --git a/themes/orange.css b/themes/orange.css new file mode 100644 index 0000000..e768a74 --- /dev/null +++ b/themes/orange.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(30 32% 10%); + --c-surface-1: hsl(30 28% 16%); + --c-surface-2: hsl(30 26% 22%); + + --c-border: hsl(30 24% 24%); + --c-border-soft: hsl(30 22% 33%); + --c-border-faint: hsl(30 28% 16%); + --c-border-strong: hsl(30 15% 62%); + + --c-text: hsl(30 15% 94%); + --c-text-secondary: hsl(30 12% 82%); + --c-text-muted: hsl(30 10% 65%); + --c-text-subtle: hsl(30 8% 50%); + --c-text-faint: hsl(30 8% 38%); + --c-text-faintest: hsl(30 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(210 70% 62%); + --c-accent-soft: hsl(180 55% 34%); + --c-accent-text: hsl(210 75% 80%); + --c-accent-2: hsl(120 65% 55%); + --c-accent-3: hsl(300 65% 68%); +} diff --git a/themes/pink.css b/themes/pink.css new file mode 100644 index 0000000..2f17d3a --- /dev/null +++ b/themes/pink.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(330 32% 10%); + --c-surface-1: hsl(330 28% 16%); + --c-surface-2: hsl(330 26% 22%); + + --c-border: hsl(330 24% 24%); + --c-border-soft: hsl(330 22% 33%); + --c-border-faint: hsl(330 28% 16%); + --c-border-strong: hsl(330 15% 62%); + + --c-text: hsl(330 15% 94%); + --c-text-secondary: hsl(330 12% 82%); + --c-text-muted: hsl(330 10% 65%); + --c-text-subtle: hsl(330 8% 50%); + --c-text-faint: hsl(330 8% 38%); + --c-text-faintest: hsl(330 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(150 70% 62%); + --c-accent-soft: hsl(120 55% 34%); + --c-accent-text: hsl(150 75% 80%); + --c-accent-2: hsl(60 65% 58%); + --c-accent-3: hsl(240 65% 68%); +} diff --git a/themes/red.css b/themes/red.css new file mode 100644 index 0000000..18d5fb0 --- /dev/null +++ b/themes/red.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(0 32% 10%); + --c-surface-1: hsl(0 28% 16%); + --c-surface-2: hsl(0 26% 22%); + + --c-border: hsl(0 24% 24%); + --c-border-soft: hsl(0 22% 33%); + --c-border-faint: hsl(0 28% 16%); + --c-border-strong: hsl(0 15% 62%); + + --c-text: hsl(0 15% 94%); + --c-text-secondary: hsl(0 12% 82%); + --c-text-muted: hsl(0 10% 65%); + --c-text-subtle: hsl(0 8% 50%); + --c-text-faint: hsl(0 8% 38%); + --c-text-faintest: hsl(0 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(180 70% 62%); + --c-accent-soft: hsl(150 55% 34%); + --c-accent-text: hsl(180 75% 80%); + --c-accent-2: hsl(90 65% 58%); + --c-accent-3: hsl(270 65% 68%); +} diff --git a/themes/yellow.css b/themes/yellow.css new file mode 100644 index 0000000..6691b2f --- /dev/null +++ b/themes/yellow.css @@ -0,0 +1,28 @@ +:root { + --c-surface-0: hsl(50 32% 10%); + --c-surface-1: hsl(50 28% 16%); + --c-surface-2: hsl(50 26% 22%); + + --c-border: hsl(50 24% 24%); + --c-border-soft: hsl(50 22% 33%); + --c-border-faint: hsl(50 28% 16%); + --c-border-strong: hsl(50 15% 62%); + + --c-text: hsl(50 15% 94%); + --c-text-secondary: hsl(50 12% 82%); + --c-text-muted: hsl(50 10% 65%); + --c-text-subtle: hsl(50 8% 50%); + --c-text-faint: hsl(50 8% 38%); + --c-text-faintest: hsl(50 10% 28%); + + --c-danger-bg: #450a0a; + --c-danger-border: #7f1d1d; + --c-danger-text: #f87171; + --c-danger-text-strong: #fca5a5; + + --c-accent: hsl(230 70% 62%); + --c-accent-soft: hsl(200 55% 34%); + --c-accent-text: hsl(230 75% 80%); + --c-accent-2: hsl(140 65% 55%); + --c-accent-3: hsl(320 65% 68%); +} diff --git a/webapp/src/components/NavMenu.tsx b/webapp/src/components/NavMenu.tsx index 92eb3b3..54e07a0 100644 --- a/webapp/src/components/NavMenu.tsx +++ b/webapp/src/components/NavMenu.tsx @@ -1,11 +1,10 @@ import { useEffect, useState } from 'react' import type { Stats } from '../lib/api' -import { cssContentUrl, sendTestAlertNotification, type ColorScheme, type CssEntry } from '../lib/api' +import { sendTestAlertNotification } from '../lib/api' import { useSettingsPanel } from '../lib/useSettingsPanel' import { useDocsNav, type DocsTarget } from '../lib/docsNav' import { DocsPanel } from './Docs' import { HelpIcon } from './HelpIcon' -import { BUILTIN_THEMES, downloadThemeFile, generateThemeCss, parseThemeCss } from '../lib/themes' import ottylIcon from '../assets/ottyl-icon.svg' type PanelName = 'settings' | 'docs' | null @@ -18,27 +17,9 @@ const PANEL_TITLES: Record, string> = { /** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */ const DEFAULT_PROFILE_NAME = 'Default' -const COLOR_SCHEMES: ColorScheme[] = [ - 'system', - 'light', - 'dark', - 'red', - 'green', - 'blue', - 'yellow', - 'pink', - 'orange', - 'light_green', - 'light_red', - 'light_blue', - 'light_yellow', - 'light_pink', - 'light_orange', -] - -/** Display label for a `ColorScheme`: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */ -function schemeLabel(scheme: ColorScheme): string { - return scheme.replace(/_/g, ' ') +/** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */ +function themeLabel(theme: string): string { + return theme.replace(/_/g, ' ') } /** Ticket 073: plain `Xms` under a second (this stat is normally near-zero, so that's the common case), a humantime-style `Xs`/`Xm` above it — mirrors the backend's own humantime-duration inputs (see `Docs.tsx`'s window-parsing note) without pulling in a full duration-formatting library for one stat. */ @@ -213,10 +194,6 @@ function PresetsDropdown({ panel }: { panel: ReturnType function SettingsPanel({ panel }: { panel: ReturnType }) { const [maxLogsInput, setMaxLogsInput] = useState('') - const [cssFilename, setCssFilename] = useState('') - const [cssContent, setCssContent] = useState('') - const [cssUploadError, setCssUploadError] = useState(null) - const [cssLinkUrl, setCssLinkUrl] = useState('') const [ntfyTopicInput, setNtfyTopicInput] = useState('') const [ntfyServerInput, setNtfyServerInput] = useState('') const [ntfyTestError, setNtfyTestError] = useState(null) @@ -272,198 +249,43 @@ function SettingsPanel({ panel }: { panel: ReturnType } panel.setMaxLogs(Math.floor(parsed)) } - async function handleCssFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0] - if (!file) return - setCssFilename(file.name) - setCssContent(await file.text()) - setCssUploadError(null) - } - - // Every imported/linked stylesheet is always a selectable style, listed alongside the built-in - // colour schemes (ticket 020/047), so it's always validated against the canonical `--c-*` - // variable set before it's sent — that's a floor, not a ceiling: the stylesheet is free to also - // carry rules for any other selector, up to and including a full layout redesign. - async function handleCssImport(e: React.FormEvent) { - e.preventDefault() - setCssUploadError(null) - if (!cssFilename.trim() || !cssContent.trim()) return - const { missing } = parseThemeCss(cssContent) - if (missing.length > 0) { - setCssUploadError(`Missing variable${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`) - return - } - await panel.importCssEntry(cssFilename.trim(), cssContent) - setCssFilename('') - setCssContent('') - } - - async function handleCssLink(e: React.FormEvent) { - e.preventDefault() - if (!cssLinkUrl.trim()) return - await panel.linkCssEntry(cssLinkUrl.trim()) - setCssLinkUrl('') - } - - function downloadBuiltinTheme(name: string) { - const vars = BUILTIN_THEMES[name] - if (!vars) return - downloadThemeFile(`${name}.css`, generateThemeCss(vars)) - } - - async function downloadCssEntry(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') - } - } - return (
-

Style

+

Theme

- one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of - them can be a simple colour swap or a full layout redesign; only one is active at a time. + each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time. + To add your own, drop a `.css` file in `themes/` and rebuild.
-
- {COLOR_SCHEMES.map((scheme) => ( -
- - {scheme !== 'system' && ( - - )} -
+
+ + {panel.themes.map((theme) => ( + ))}
- - {panel.cssEntries.length === 0 ? ( -
No styles imported or linked yet.
- ) : ( -
    - {panel.cssEntries.map((entry) => ( -
  • -
    - -
    {entry.kind}
    -
    -
    - - {entry.kind === 'imported' ? ( - - ) : ( - - — - - )} -
    -
  • - ))} -
- )} - -

- Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '} - --c-* variable the built-ins do, but nothing stops it from also targeting any other selector - in the app (layout, spacing, fonts, anything) — it isn't limited to palette overrides. -

- -
- - setCssFilename(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" - /> -