Remove uploading css functionality. Themes are hard-coded. Change the code

This commit is contained in:
Hugh Kaznowski
2026-09-07 01:02:41 +01:00
parent 901dcf0a3f
commit 82fa0dfeb4
24 changed files with 578 additions and 1702 deletions
+4 -6
View File
@@ -124,12 +124,10 @@ instance's active profile.
### Settings & theming ### Settings & theming
- **`get_settings`** / **`update_settings`** — read/update color scheme, active profile, - **`get_settings`** / **`update_settings`** — read/update the selected theme, active profile,
`max_logs`, custom theme, ntfy topic/server. `max_logs`, ntfy topic/server.
- **`list_css`** — list imported/linked custom CSS theme entries. - **`list_themes`** — list the names of the themes embedded in this build.
- **`import_css`** — import a CSS theme by file content. - **`get_theme_content`** — read a theme's raw CSS content by name.
- **`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.
### Alerts ### Alerts
+4 -53
View File
@@ -2,7 +2,7 @@ use crate::engine::alerts::AlertCondition;
use crate::engine::data_value::MetricPrimitiveKind; use crate::engine::data_value::MetricPrimitiveKind;
use crate::engine::metric_primitive::MetricPrimitive; use crate::engine::metric_primitive::MetricPrimitive;
use crate::config::profile::{ChartType, Dashboard, Panel, Profile}; use crate::config::profile::{ChartType, Dashboard, Panel, Profile};
use crate::config::{ColorScheme, CssEntry, Settings}; use crate::config::Settings;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -108,57 +108,11 @@ pub struct ErrorDto {
pub error: String, 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<String>,
pub path_or_url: Option<String>,
}
#[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)] #[derive(Debug, Clone, Serialize)]
pub struct SettingsDto { pub struct SettingsDto {
pub color_scheme: ColorScheme, pub theme: String,
pub active_profile: Option<String>, pub active_profile: Option<String>,
pub max_logs: Option<usize>, pub max_logs: Option<usize>,
pub custom_theme_id: Option<String>,
pub ntfy_topic: Option<String>, pub ntfy_topic: Option<String>,
pub ntfy_server: Option<String>, pub ntfy_server: Option<String>,
} }
@@ -166,10 +120,9 @@ pub struct SettingsDto {
impl From<&Settings> for SettingsDto { impl From<&Settings> for SettingsDto {
fn from(settings: &Settings) -> Self { fn from(settings: &Settings) -> Self {
SettingsDto { SettingsDto {
color_scheme: settings.color_scheme, theme: settings.theme.clone(),
active_profile: settings.active_profile.clone(), active_profile: settings.active_profile.clone(),
max_logs: settings.max_logs, max_logs: settings.max_logs,
custom_theme_id: settings.custom_theme_id.clone(),
ntfy_topic: settings.ntfy_topic.clone(), ntfy_topic: settings.ntfy_topic.clone(),
ntfy_server: settings.ntfy_server.clone(), ntfy_server: settings.ntfy_server.clone(),
} }
@@ -178,12 +131,10 @@ impl From<&Settings> for SettingsDto {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub struct UpdateSettingsRequest { pub struct UpdateSettingsRequest {
pub color_scheme: Option<ColorScheme>, pub theme: Option<String>,
#[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")] #[serde(default, deserialize_with = "deserialize_double_option")]
pub custom_theme_id: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_topic: Option<Option<String>>, pub ntfy_topic: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_double_option")] #[serde(default, deserialize_with = "deserialize_double_option")]
pub ntfy_server: Option<Option<String>>, pub ntfy_server: Option<Option<String>>,
+14 -60
View File
@@ -186,36 +186,23 @@ pub struct DeleteDashboardParams {
pub dashboard_id: String, pub dashboard_id: String,
} }
// ---- settings / css ---- // ---- settings / theming ----
#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] #[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
pub struct UpdateSettingsParams { pub struct UpdateSettingsParams {
#[serde(default)] #[serde(default)]
pub color_scheme: Option<String>, pub theme: Option<String>,
#[serde(default)] #[serde(default)]
pub max_logs: Option<Option<usize>>, pub max_logs: Option<Option<usize>>,
#[serde(default)] #[serde(default)]
pub custom_theme_id: Option<Option<String>>,
#[serde(default)]
pub ntfy_topic: Option<Option<String>>, pub ntfy_topic: Option<Option<String>>,
#[serde(default)] #[serde(default)]
pub ntfy_server: Option<Option<String>>, pub ntfy_server: Option<Option<String>>,
} }
#[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)] #[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct CssIdParams { pub struct GetThemeContentParams {
pub id: String, pub name: String,
} }
// ---- alerts ---- // ---- alerts ----
@@ -734,7 +721,7 @@ impl OttylMcp {
// ---- settings / theming ---- // ---- 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<CallToolResult, McpError> { async fn get_settings(&self) -> Result<CallToolResult, McpError> {
json_result(self.get_json("/api/settings", &[]).await?) 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?) json_result(self.send_json(reqwest::Method::PATCH, "/api/settings", Some(&body)).await?)
} }
#[tool(description = "List imported/linked custom CSS theme entries.")] #[tool(description = "List the names of the themes embedded in this build (see /api/themes).")]
async fn list_css(&self) -> Result<CallToolResult, McpError> { async fn list_themes(&self) -> Result<CallToolResult, McpError> {
json_result(self.get_json("/api/settings/css", &[]).await?) json_result(self.get_json("/api/themes", &[]).await?)
} }
#[tool(description = "Import a custom CSS theme file by content.")] #[tool(description = "Read the raw CSS content of a theme by name.")]
async fn import_css( async fn get_theme_content(
&self, &self,
Parameters(params): Parameters<ImportCssParams>, Parameters(params): Parameters<GetThemeContentParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params).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<LinkCssParams>,
) -> Result<CallToolResult, McpError> {
let body = serde_json::to_value(&params).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<CssIdParams>,
) -> Result<CallToolResult, McpError> {
json_result(
self.send_json(
reqwest::Method::DELETE,
&format!("/api/settings/css/{}", urlencoding_path(&params.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<CssIdParams>,
) -> Result<CallToolResult, McpError> { ) -> Result<CallToolResult, McpError> {
let response = self let response = self
.client .client
.get(self.url(&format!("/api/settings/css/{}/content", urlencoding_path(&params.id)))) .get(self.url(&format!("/api/themes/{}", urlencoding_path(&params.name))))
.send() .send()
.await .await
.map_err(|err| mcp_error("failed to reach ottyl", err))?; .map_err(|err| mcp_error("failed to reach ottyl", err))?;
@@ -801,7 +755,7 @@ impl OttylMcp {
let text = response let text = response
.text() .text()
.await .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() { if !status.is_success() {
return Err(mcp_error("ottyl rejected the request", text)); 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: \ "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 \ logs/timeline/stats, param & metric declarations, PromQL queries, dashboards & panels \
(including render_panel/render_dashboard, which return a chart's underlying series data \ (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 \ alert rules, and profile management. See the embedded SKILL.md (GET /SKILL.md on the \
target instance) for examples." target instance) for examples."
.to_string(), .to_string(),
+32 -96
View File
@@ -2,12 +2,12 @@ pub mod dto;
pub mod mcp_server; pub mod mcp_server;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use crate::api::dto::{ use crate::api::dto::{
BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest, BucketValuesParams, CreateAlertRuleRequest, CreateDashboardRequest, CreateProfileRequest,
CssEntryDto, DashboardDto, ErrorDto, ImportCssRequest, LinkCssRequest, MetricDeclarationDto, DashboardDto, ErrorDto, MetricDeclarationDto,
ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest, ParamDeclarationDto, LogsParams, ProfileSummaryDto, PromqlParams, SaveDashboardRequest,
SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest, SettingsDto, TestAlertNotificationRequest, TimelineParams, UpdateAlertRuleRequest, UpdateSettingsRequest,
}; };
@@ -18,7 +18,7 @@ use crate::engine::{
}; };
use crate::engine::alerts::{deliver_ntfy, AlertError}; use crate::engine::alerts::{deliver_ntfy, AlertError};
use crate::config::profile::{Dashboard, ParamDeclarationRecord, Profile, ProfileError}; 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::extract::{Path as AxumPath, Query, State};
use axum::http::{header, StatusCode}; use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Json, Response}; use axum::response::{IntoResponse, Json, Response};
@@ -29,6 +29,11 @@ use tokio::sync::oneshot;
static WEBAPP_DIST: EmbedDir = fs_embed::fs_embed!("webapp/dist"); 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"); const SKILL_MD: &str = include_str!("../../SKILL.md");
#[derive(Clone)] #[derive(Clone)]
@@ -39,13 +44,6 @@ struct ApiState {
} }
impl 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) { fn persist_param_declaration(&self, request: &DeclareParamRequest) {
let mut settings = self.settings.lock().unwrap(); let mut settings = self.settings.lock().unwrap();
let Some(profile_name) = settings.active_profile.clone() else { 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/query", get(run_promql_query))
.route("/api/settings", get(get_settings).patch(update_settings)) .route("/api/settings", get(get_settings).patch(update_settings))
.route("/api/settings/css", get(list_css).post(import_css)) .route("/api/themes", get(list_themes))
.route("/api/settings/css/link", post(link_css)) .route("/api/themes/{name}", get(get_theme_content))
.route("/api/settings/css/{id}", delete(remove_css))
.route("/api/settings/css/{id}/content", get(get_css_content))
.route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule)) .route("/api/settings/alerts", get(list_alert_rules).post(create_alert_rule))
.route( .route(
"/api/settings/alerts/{id}", "/api/settings/alerts/{id}",
@@ -553,84 +549,27 @@ async fn run_promql_query(
} }
} }
impl CssError { async fn list_themes() -> impl IntoResponse {
fn into_response(self) -> axum::response::Response { let mut names: Vec<String> = THEMES
match self { .walk()
CssError::NotFound => (StatusCode::NOT_FOUND, self.to_string()).into_response(), .filter_map(|file| file.file_name().map(|name| name.trim_end_matches(".css").to_string()))
CssError::NotImported => (StatusCode::BAD_REQUEST, self.to_string()).into_response(), .collect();
CssError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response(), names.sort();
Json(names).into_response()
}
async fn get_theme_content(AxumPath(name): AxumPath<String>) -> 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()
} }
} None => StatusCode::NOT_FOUND.into_response(),
}
async fn list_css(State(state): State<ApiState>) -> impl IntoResponse {
let settings = state.settings.lock().unwrap();
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
Json(entries).into_response()
}
async fn import_css(
State(state): State<ApiState>,
Json(body): Json<ImportCssRequest>,
) -> 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<ApiState>,
Json(body): Json<LinkCssRequest>,
) -> impl IntoResponse {
let mut settings = state.settings.lock().unwrap();
match settings.link_css(&state.settings_path, body.path_or_url.clone()) {
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<ApiState>,
AxumPath(id): AxumPath<String>,
) -> 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<ApiState>,
AxumPath(id): AxumPath<String>,
) -> 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(),
} }
} }
@@ -699,15 +638,12 @@ async fn update_settings(
let max_logs_changed = body.max_logs.is_some(); let max_logs_changed = body.max_logs.is_some();
let new_max_logs = { let new_max_logs = {
let mut settings = state.settings.lock().unwrap(); let mut settings = state.settings.lock().unwrap();
if let Some(color_scheme) = body.color_scheme { if let Some(theme) = body.theme {
settings.color_scheme = color_scheme; settings.theme = theme;
} }
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;
}
if let Some(ntfy_topic) = body.ntfy_topic { if let Some(ntfy_topic) = body.ntfy_topic {
settings.ntfy_topic = ntfy_topic; settings.ntfy_topic = ntfy_topic;
} }
+40 -288
View File
@@ -1,13 +1,9 @@
pub mod profile; pub mod profile;
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::fs; use std::fs;
use std::hash::{Hash, Hasher};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -17,39 +13,10 @@ use crate::config::profile::Profile;
pub type SettingsHandle = Arc<Mutex<Settings>>; pub type SettingsHandle = Arc<Mutex<Settings>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] /// Name of the selected theme file in `themes/` (see `THEMES` in `src/api/mod.rs`), or
#[serde(rename_all = "lowercase")] /// `"system"` to follow the OS light/dark preference between the `dark`/`light` themes.
pub enum ColorScheme { fn default_theme() -> String {
Light, "system".to_string()
#[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 },
} }
#[derive(Debug, Clone, Deserialize, Default)] #[derive(Debug, Clone, Deserialize, Default)]
@@ -76,11 +43,13 @@ impl Default for ProfilesOnDisk {
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct SettingsOnDisk { struct SettingsOnDisk {
color_scheme: ColorScheme,
#[serde(default)] #[serde(default)]
css_entries: Vec<CssEntry>, theme: Option<String>,
// 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)] #[serde(default)]
custom_theme_id: Option<String>, color_scheme: Option<String>,
#[serde(default)] #[serde(default)]
profiles: ProfilesOnDisk, profiles: ProfilesOnDisk,
#[serde(default)] #[serde(default)]
@@ -101,10 +70,15 @@ impl From<SettingsOnDisk> for Settings {
ProfilesOnDisk::Inline(profiles) => (profiles, None), ProfilesOnDisk::Inline(profiles) => (profiles, None),
ProfilesOnDisk::Legacy(pointer) => (Vec::new(), pointer.active_profile), 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 { Settings {
color_scheme: on_disk.color_scheme, theme,
css_entries: on_disk.css_entries,
custom_theme_id: on_disk.custom_theme_id,
profiles, profiles,
active_profile: on_disk.active_profile.or(legacy_active_profile), active_profile: on_disk.active_profile.or(legacy_active_profile),
max_logs: on_disk.max_logs, max_logs: on_disk.max_logs,
@@ -115,13 +89,11 @@ impl From<SettingsOnDisk> for Settings {
} }
} }
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(from = "SettingsOnDisk")] #[serde(from = "SettingsOnDisk")]
pub struct Settings { pub struct Settings {
pub color_scheme: ColorScheme, #[serde(default = "default_theme")]
pub css_entries: Vec<CssEntry>, pub theme: String,
#[serde(default)]
pub custom_theme_id: Option<String>,
pub profiles: Vec<Profile>, pub profiles: Vec<Profile>,
pub active_profile: Option<String>, pub active_profile: Option<String>,
#[serde(default)] #[serde(default)]
@@ -134,6 +106,20 @@ pub struct Settings {
pub ntfy_server: Option<String>, pub ntfy_server: Option<String>,
} }
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<PathBuf> { pub fn default_settings_path() -> Option<PathBuf> {
ProjectDirs::from( ProjectDirs::from(
crate::consts::APP_QUALIFIER, 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<io::Error> 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<String, CssError> {
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<String, CssError> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -371,29 +244,12 @@ mod tests {
} }
#[test] #[test]
fn light_variant_color_schemes_round_trip_through_serde() { fn legacy_grey_color_scheme_migrates_to_dark_theme() {
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() {
let settings: Settings = serde_json::from_str( 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}"#, 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(); .unwrap();
assert_eq!(settings.color_scheme, ColorScheme::Dark); assert_eq!(settings.theme, "dark");
} }
#[test] #[test]
@@ -411,15 +267,10 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("settings.json"); let path = dir.path().join("settings.json");
let mut settings = Settings::default(); let mut settings = Settings {
settings.color_scheme = ColorScheme::Dark; theme: "dark".to_string(),
settings.css_entries.push(CssEntry::Imported { ..Settings::default()
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(),
});
settings.profiles.push(crate::config::profile::Profile::new("default".to_string())); settings.profiles.push(crate::config::profile::Profile::new("default".to_string()));
settings.active_profile = Some("default".to_string()); settings.active_profile = Some("default".to_string());
settings.alert_rules.push(crate::engine::alerts::AlertRule { settings.alert_rules.push(crate::engine::alerts::AlertRule {
@@ -449,105 +300,6 @@ mod tests {
assert!(path.exists()); 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] #[test]
fn load_migrates_legacy_profiles_file_into_inline_settings() { fn load_migrates_legacy_profiles_file_into_inline_settings() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+28
View File
@@ -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%);
}
+28
View File
@@ -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;
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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;
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+28
View File
@@ -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%);
}
+32 -210
View File
@@ -1,11 +1,10 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { Stats } from '../lib/api' 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 { 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'
import ottylIcon from '../assets/ottyl-icon.svg' import ottylIcon from '../assets/ottyl-icon.svg'
type PanelName = 'settings' | 'docs' | null type PanelName = 'settings' | 'docs' | null
@@ -18,27 +17,9 @@ const PANEL_TITLES: Record<Exclude<PanelName, null>, string> = {
/** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */ /** Mirrors the backend's `profile::DEFAULT_PROFILE_NAME` — always exists, can't be deleted. */
const DEFAULT_PROFILE_NAME = 'Default' const DEFAULT_PROFILE_NAME = 'Default'
const COLOR_SCHEMES: ColorScheme[] = [ /** Display label for a theme name: underscores become spaces so `capitalize` (which titles each word) renders e.g. "light_green" as "Light Green". */
'system', function themeLabel(theme: string): string {
'light', return theme.replace(/_/g, ' ')
'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, ' ')
} }
/** 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. */ /** 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<typeof useSettingsPanel>
function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) { function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }) {
const [maxLogsInput, setMaxLogsInput] = useState('') const [maxLogsInput, setMaxLogsInput] = useState('')
const [cssFilename, setCssFilename] = useState('')
const [cssContent, setCssContent] = useState('')
const [cssUploadError, setCssUploadError] = useState<string | null>(null)
const [cssLinkUrl, setCssLinkUrl] = useState('')
const [ntfyTopicInput, setNtfyTopicInput] = useState('') const [ntfyTopicInput, setNtfyTopicInput] = useState('')
const [ntfyServerInput, setNtfyServerInput] = useState('') const [ntfyServerInput, setNtfyServerInput] = useState('')
const [ntfyTestError, setNtfyTestError] = useState<string | null>(null) const [ntfyTestError, setNtfyTestError] = useState<string | null>(null)
@@ -272,198 +249,43 @@ function SettingsPanel({ panel }: { panel: ReturnType<typeof useSettingsPanel> }
panel.setMaxLogs(Math.floor(parsed)) panel.setMaxLogs(Math.floor(parsed))
} }
async function handleCssFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 ( 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>
<div className="mb-2 flex items-center gap-1.5"> <div className="mb-2 flex items-center gap-1.5">
<h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Style</h3> <h3 className="text-xs font-semibold uppercase tracking-wide text-text-subtle">Theme</h3>
<span className="text-[11px] text-text-faint"> <span className="text-[11px] text-text-faint">
one list of interchangeable styles — a built-in palette or your own imported/linked stylesheet. Any of each theme is a complete stylesheet (colours, spacing, fonts, sizes, ...); only one is active at a time.
them can be a simple colour swap or a full layout redesign; only one is active at a time. To add your own, drop a `.css` file in `themes/` and rebuild.
</span> </span>
</div> </div>
<div className="mb-3 flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{COLOR_SCHEMES.map((scheme) => ( <button
<div key={scheme} className="flex items-center"> type="button"
<button onClick={() => panel.setTheme('system')}
type="button" className={`rounded border px-2 py-1 text-xs capitalize ${
onClick={() => panel.setColorScheme(scheme)} panel.settings?.theme === 'system'
className={`rounded-l border px-2 py-1 text-xs capitalize ${ ? 'border-border-strong bg-surface-2 text-text'
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme : 'border-border-soft text-text-muted hover:bg-surface-2'
? 'border-border-strong bg-surface-2 text-text' }`}
: 'border-border-soft text-text-muted hover:bg-surface-2' >
}`} System
> </button>
{schemeLabel(scheme)} {panel.themes.map((theme) => (
</button> <button
{scheme !== 'system' && ( key={theme}
<button type="button"
type="button" onClick={() => panel.setTheme(theme)}
onClick={() => downloadBuiltinTheme(scheme)} className={`rounded border px-2 py-1 text-xs capitalize ${
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" panel.settings?.theme === theme
aria-label={`Download ${schemeLabel(scheme)} theme`} ? 'border-border-strong bg-surface-2 text-text'
title="Download as .css" : 'border-border-soft text-text-muted hover:bg-surface-2'
> }`}
↓ >
</button> {themeLabel(theme)}
)} </button>
</div>
))} ))}
</div> </div>
{panel.cssEntries.length === 0 ? (
<div className="mb-2 text-xs text-text-faint">No styles imported or linked yet.</div>
) : (
<ul className="mb-2 flex flex-col gap-1">
{panel.cssEntries.map((entry) => (
<li
key={entry.id}
className="flex items-center justify-between rounded border border-border px-2 py-1 text-xs"
>
<div className="min-w-0 flex-1">
<button
type="button"
onClick={() => panel.setCustomTheme(entry.id)}
className={`block w-full 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="text-[10px] uppercase text-text-faint">{entry.kind}</div>
</div>
<div className="ml-2 flex shrink-0 items-center gap-1">
<button
type="button"
onClick={() => downloadCssEntry(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>
)}
<p className="mb-2 text-[11px] text-text-faint">
Add your own style below — a stylesheet, uploaded or linked by URL/path. It must at least define every{' '}
<code>--c-*</code> 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.
</p>
<form onSubmit={handleCssImport} className="flex flex-col gap-2">
<input
type="file"
accept=".css,text/css"
onChange={handleCssFileChange}
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="enter name"
value={cssFilename}
onChange={(e) => 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"
/>
<textarea
placeholder="Paste CSS content here"
value={cssContent}
onChange={(e) => setCssContent(e.target.value)}
rows={4}
className="resize-y rounded border border-border-soft bg-surface-1 px-2 py-1 font-mono text-xs text-text placeholder:text-text-subtle focus:border-border-strong focus:outline-none"
/>
{cssUploadError && <div className="text-xs text-danger-text-strong">{cssUploadError}</div>}
<button
type="submit"
className="rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Import
</button>
</form>
<form onSubmit={handleCssLink} className="mt-2 flex flex-col gap-2">
<input
type="text"
placeholder="https://example.com/style.css"
value={cssLinkUrl}
onChange={(e) => setCssLinkUrl(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"
/>
<div className="flex items-center gap-2">
<button
type="submit"
className="shrink-0 rounded border border-border-soft px-2 py-1 text-xs text-text hover:bg-surface-2"
>
Link
</button>
</div>
</form>
</section> </section>
<section> <section>
+9 -411
View File
@@ -12,36 +12,16 @@
* danger banners, accent highlights) is a plain CSS custom property here, * danger banners, accent highlights) is a plain CSS custom property here,
* bridged into real Tailwind utilities (bg-surface-0, text-text-muted, * bridged into real Tailwind utilities (bg-surface-0, text-text-muted,
* border-accent, ...) via `@theme inline`. Components use those utilities * border-accent, ...) via `@theme inline`. Components use those utilities
* instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...), * instead of hardcoded palette classes (bg-neutral-950, text-sky-400, ...).
* so re-declaring the `--c-*` variables under a `html.<theme>` selector
* re-skins the whole app with no per-component changes.
* *
* Derivation: `dark` uses one true black-based neutral (S=0) scale, `light` * The actual theme files live in `themes/` at the repo root (embedded into
* is its exact lightness mirror — these two stay grey throughout. (There * the binary at build time — see `THEMES` in `src/api/mod.rs`), one CSS file
* used to be a separate `grey` theme sharing `dark`'s neutral scale with * per theme, each free to redeclare `--c-*` under `:root` and/or add any
* only its accent trio differing; it was a near-duplicate of `dark` and was * other rules (spacing, fonts, layout, ...) it wants. The `:root` block below
* removed in favour of a single `dark` theme — ticket 004. Settings saved * is only the pre-theme-load fallback (identical to `themes/dark.css`) so
* with `color_scheme: "grey"` still load fine: the backend aliases it to * there's no flash of unstyled content before the selected theme's
* `dark`, see `ColorScheme` in `src/settings.rs`.) * stylesheet is injected — see `useSettingsPanel.ts`. To add a new theme,
* Each named theme (red/green/blue/yellow/pink/orange) instead tints its * drop another file in `themes/` and rebuild; no code changes needed.
* whole surface/border/text scale in its own hue at low saturation (so the
* chrome itself reads as "a shade of red", not grey-with-a-red-light), and
* pulls its accent trio from two *other* hues on the wheel: --c-accent and
* --c-accent-text share the base hue's complement (base+180°), while
* --c-accent-soft sits at a split-complement (~base+150°/-150°) — a
* "composite" accent distinct from the straight complement. To add a new
* themed variant: pick a base hue H, a complement C = H+180, and a
* composite K = H+150 (mod 360), then fill --c-surface-, --c-border-,
* --c-text- as hsl(H, ...) at the saturation/lightness steps below, accent
* + accent-text as hsl(C, ...), and accent-soft as hsl(K, ...).
*
* --c-accent (base look, unchanged above) is joined by two more accent
* colours (ticket 048) so charts/highlights can draw from a 3-colour set
* instead of one hue: --c-accent-2 sits at H+90 and --c-accent-3 at H+270 —
* a quadrature pair straddling the base hue, distinct from both the
* complement (accent) and the split-complement (accent-soft). These are
* purely additive: nothing that already reads --c-accent/-soft/-text
* changes look.
*/ */
:root { :root {
color-scheme: light dark; color-scheme: light dark;
@@ -74,388 +54,6 @@
--c-accent-3: #4ade80; --c-accent-3: #4ade80;
} }
/* Exact lightness mirror of :root's neutral-950..50 scale (Tailwind neutral,
steps flipped: 950<->50, 900<->100, 800<->200, 700<->300, 600<->400). */
html.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;
--c-accent-2: #7c3aed;
--c-accent-3: #16a34a;
}
/* base hue 0 (red): surfaces/borders/text tinted red; accent complement 180
(cyan), accent-soft composite 150 (spring green) */
html.red {
--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-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%);
}
/* base hue 140 (green): surfaces/borders/text tinted green; accent complement
320 (magenta), accent-soft composite 290 (purple) */
html.green {
--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-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%);
}
/* base hue 210 (blue): surfaces/borders/text tinted blue; accent complement
30 (orange), accent-soft composite 0 (red) */
html.blue {
--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-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%);
}
/* base hue 50 (yellow): surfaces/borders/text tinted yellow; accent
complement 230 (blue), accent-soft composite 200 (azure) */
html.yellow {
--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-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%);
}
/* base hue 330 (pink): surfaces/borders/text tinted pink; accent complement
150 (spring green), accent-soft composite 120 (green) */
html.pink {
--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-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%);
}
/* base hue 30 (orange): surfaces/borders/text tinted orange; accent
complement 210 (blue), accent-soft composite 180 (cyan) */
html.orange {
--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-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%);
}
/* Brighter, light-surfaced counterpart to `green` (ticket 048): same base
hue (140) and accent hues as `green`, but with a `light`-style inverted
surface/border/text scale (high lightness instead of low) so it reads as
a distinctly brighter theme, not a re-tint of the dark `green`. Danger
colours are `light`'s (hue-neutral), matching how `light` itself doesn't
tint danger either. */
html.light_green {
--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%);
}
/* Brighter, light-surfaced counterpart to `red` (ticket 056): same base hue
(0) and accent hues as `red`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_red {
--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%);
}
/* Brighter, light-surfaced counterpart to `blue` (ticket 056): same base hue
(210) and accent hues as `blue`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_blue {
--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%);
}
/* Brighter, light-surfaced counterpart to `yellow` (ticket 056): same base
hue (50) and accent hues as `yellow`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_yellow {
--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%);
}
/* Brighter, light-surfaced counterpart to `pink` (ticket 056): same base hue
(330) and accent hues as `pink`, with `light`'s inverted surface/border/text
scale and `light`'s hue-neutral danger colours. */
html.light_pink {
--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%);
}
/* Brighter, light-surfaced counterpart to `orange` (ticket 056): same base
hue (30) and accent hues as `orange`, with `light`'s inverted
surface/border/text scale and `light`'s hue-neutral danger colours. */
html.light_orange {
--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%);
}
@theme inline { @theme inline {
--color-surface-0: var(--c-surface-0); --color-surface-0: var(--c-surface-0);
--color-surface-1: var(--c-surface-1); --color-surface-1: var(--c-surface-1);
+10 -49
View File
@@ -84,45 +84,20 @@ export class ApiError extends Error {
} }
} }
export type ColorScheme = /** Name of a theme embedded in this build (see `GET /api/themes`), or `"system"` to follow the OS light/dark preference. */
| 'light' export type Theme = string
| 'dark'
| 'system'
| 'red'
| 'green'
| 'blue'
| 'yellow'
| 'pink'
| 'orange'
| 'light_green'
| 'light_red'
| 'light_blue'
| 'light_yellow'
| 'light_pink'
| 'light_orange'
export interface Settings { export interface Settings {
color_scheme: ColorScheme theme: Theme
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, or `null` if `color_scheme` alone applies. */
custom_theme_id: string | null
/** Ntfy topic every alert rule delivers to; `null` until configured. */ /** Ntfy topic every alert rule delivers to; `null` until configured. */
ntfy_topic: string | null ntfy_topic: string | null
/** Ntfy server override; `null` means `https://ntfy.sh`. */ /** Ntfy server override; `null` means `https://ntfy.sh`. */
ntfy_server: string | null ntfy_server: string | null
} }
export type CssEntryKind = 'imported' | 'linked'
export interface CssEntry {
id: string
kind: CssEntryKind
filename: string | null
path_or_url: string | null
}
export interface ProfileSummary { export interface ProfileSummary {
name: string name: string
param_count: number param_count: number
@@ -352,42 +327,28 @@ export function fetchSettings(): Promise<Settings> {
} }
export function updateSettings(update: { export function updateSettings(update: {
colorScheme?: ColorScheme theme?: Theme
/** 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
/** Omit to leave unchanged; `null` clears the configured topic. */ /** Omit to leave unchanged; `null` clears the configured topic. */
ntfyTopic?: string | null ntfyTopic?: string | null
/** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */ /** Omit to leave unchanged; `null` falls back to `https://ntfy.sh`. */
ntfyServer?: string | null ntfyServer?: string | null
}): Promise<Settings> { }): Promise<Settings> {
const body: Record<string, unknown> = { color_scheme: update.colorScheme } const body: Record<string, unknown> = { theme: update.theme }
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
if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic if ('ntfyTopic' in update) body.ntfy_topic = update.ntfyTopic
if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer if ('ntfyServer' in update) body.ntfy_server = update.ntfyServer
return sendJson('/api/settings', 'PATCH', body) return sendJson('/api/settings', 'PATCH', body)
} }
export function fetchCssEntries(): Promise<CssEntry[]> { /** Names of the themes embedded in this build (see `themes/` at the repo root). */
return getJson('/api/settings/css') export function fetchThemes(): Promise<string[]> {
return getJson('/api/themes')
} }
export function importCss(filename: string, content: string): Promise<CssEntry> { export function themeCssUrl(name: string): string {
return sendJson('/api/settings/css', 'POST', { filename, content }) return `/api/themes/${encodeURIComponent(name)}`
}
export function linkCss(pathOrUrl: string): Promise<CssEntry> {
return sendJson('/api/settings/css/link', 'POST', { path_or_url: pathOrUrl })
}
export function deleteCssEntry(id: string): Promise<void> {
return sendJson(`/api/settings/css/${encodeURIComponent(id)}`, 'DELETE')
}
export function cssContentUrl(id: string): string {
return `/api/settings/css/${encodeURIComponent(id)}/content`
} }
export function fetchProfiles(): Promise<ProfileSummary[]> { export function fetchProfiles(): Promise<ProfileSummary[]> {
-412
View File
@@ -1,412 +0,0 @@
// A user-supplied style is any CSS file that defines every variable in `THEME_CSS_VARS` somewhere in
// a `:root { --c-*: ...; }` block (ticket 020/047: styles are selectable on equal footing with the
// built-ins, and there's no requirement that this block be the *only* thing the file contains — a
// style is free to also target any other selector, e.g. to restyle layout, spacing, or fonts, not
// just the palette). `useSettingsPanel` applies the selected one as an unscoped stylesheet only
// while no built-in `html.<scheme>` class is present, so it wins the cascade without rewriting the
// selector.
/** 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',
'--c-accent-2',
'--c-accent-3',
] as const
export type ThemeVars = Record<(typeof THEME_CSS_VARS)[number], string>
// Fully-resolved variable values for each built-in theme, including inherited fallbacks, so a downloaded file is always complete; keep in sync with `index.css` by hand, there's no build step linking the two.
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',
'--c-accent-2': '#a78bfa',
'--c-accent-3': '#4ade80',
},
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',
'--c-accent-2': '#7c3aed',
'--c-accent-3': '#16a34a',
},
red: {
'--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%)',
},
green: {
'--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%)',
},
blue: {
'--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%)',
},
yellow: {
'--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%)',
},
pink: {
'--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%)',
},
orange: {
'--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%)',
},
light_green: {
'--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%)',
},
light_red: {
'--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%)',
},
light_blue: {
'--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%)',
},
light_yellow: {
'--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%)',
},
light_pink: {
'--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%)',
},
light_orange: {
'--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%)',
},
}
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 `--c-*: value;` declarations from the first `:root { ... }` block (or the whole file, tolerant of a dropped selector); reports missing canonical variables so callers can reject an incomplete upload.
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 }
}
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)
}
+41 -117
View File
@@ -2,75 +2,49 @@ import { useCallback, useEffect, useState } from 'react'
import { import {
activateProfile, activateProfile,
createProfile, createProfile,
cssContentUrl,
deleteCssEntry,
deleteProfile, deleteProfile,
fetchCssEntries,
fetchProfiles, fetchProfiles,
fetchSettings, fetchSettings,
importCss, fetchThemes,
linkCss, themeCssUrl,
updateSettings, updateSettings,
type ColorScheme,
type CssEntry,
type ProfileSummary, type ProfileSummary,
type Settings, type Settings,
type Theme,
} from './api' } from './api'
// `data-*` marker for the single injected `<link>` for the active custom theme, if any. // `data-*` marker for the single injected `<link>` for the active theme's stylesheet.
const INJECTED_THEME_ATTR = 'data-ottyl-theme-css-id' const INJECTED_THEME_ATTR = 'data-ottyl-theme-css'
// Themes selectable independently of `system`, each backed by a `html.<name>` block in `index.css`. // `"system"` isn't an embedded theme file — it resolves to one of these two at apply time, based
const THEME_CLASSES = [ // on the OS light/dark preference.
'light', const SYSTEM_LIGHT_THEME = 'light'
'dark', const SYSTEM_DARK_THEME = 'dark'
'red',
'green',
'blue',
'yellow',
'pink',
'orange',
'light_green',
'light_red',
'light_blue',
'light_yellow',
'light_pink',
'light_orange',
] as const
// Reflects `colorScheme` onto `<html>` as a class + `data-color-scheme`; when a custom theme is active, no built-in class is added at all, since a class selector would out-specificity its injected `:root` override (see `applyCustomThemeCss`) and mask it. function resolveTheme(theme: Theme): string {
function applyColorScheme(colorScheme: ColorScheme, customThemeActive: boolean) { if (theme !== 'system') return theme
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 return prefersDark ? SYSTEM_DARK_THEME : SYSTEM_LIGHT_THEME
root.classList.add(effective)
root.dataset.colorScheme = colorScheme
} }
// Injects a `<link>` for the active custom theme entry, if any (ticket 020: every CSS entry is // Injects a `<link>` for the resolved theme's stylesheet, replacing any previously-injected one.
// always a selectable theme now, so this is the only injection path — there's no more // `webapp/src/index.css`'s `:root` block is the only styling until this loads, so there's no
// always-on-overlay mode). `entry` null just removes any previously-injected tag. // flash of unstyled content on first paint.
function applyCustomThemeCss(entry: CssEntry | null) { function applyTheme(theme: Theme) {
const resolved = resolveTheme(theme)
document.querySelectorAll(`link[${INJECTED_THEME_ATTR}]`).forEach((el) => el.remove()) 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') const link = document.createElement('link')
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = href link.href = themeCssUrl(resolved)
link.setAttribute(INJECTED_THEME_ATTR, entry.id) link.setAttribute(INJECTED_THEME_ATTR, resolved)
document.head.appendChild(link) document.head.appendChild(link)
} }
// Owns fetching + mutating settings/CSS/profile state for the NavMenu panels, and applies colour scheme / imported CSS to the document as a side effect so changes are visible immediately, with no reload. // Owns fetching + mutating settings/theme/profile state for the NavMenu panels, and applies the
// selected theme to the document as a side effect so changes are visible immediately, with no reload.
export function useSettingsPanel() { export function useSettingsPanel() {
const [settings, setSettings] = useState<Settings | null>(null) const [settings, setSettings] = useState<Settings | null>(null)
const [cssEntries, setCssEntries] = useState<CssEntry[]>([]) const [themes, setThemes] = useState<string[]>([])
const [profiles, setProfiles] = useState<ProfileSummary[]>([]) const [profiles, setProfiles] = useState<ProfileSummary[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@@ -80,9 +54,9 @@ export function useSettingsPanel() {
return next return next
}, []) }, [])
const refreshCss = useCallback(async () => { const refreshThemes = useCallback(async () => {
const next = await fetchCssEntries() const next = await fetchThemes()
setCssEntries(next) setThemes(next)
return next return next
}, []) }, [])
@@ -94,21 +68,20 @@ export function useSettingsPanel() {
useEffect(() => { useEffect(() => {
refreshSettings().catch(() => {}) refreshSettings().catch(() => {})
refreshCss().catch(() => {}) refreshThemes().catch(() => {})
refreshProfiles().catch(() => {}) refreshProfiles().catch(() => {})
}, [refreshSettings, refreshCss, refreshProfiles]) }, [refreshSettings, refreshThemes, refreshProfiles])
const activeCustomTheme = settings?.custom_theme_id
? cssEntries.find((entry) => entry.id === settings.custom_theme_id) ?? null
: null
useEffect(() => { useEffect(() => {
if (settings) applyColorScheme(settings.color_scheme, activeCustomTheme !== null) if (!settings) return
}, [settings, activeCustomTheme]) applyTheme(settings.theme)
if (settings.theme !== 'system') return
useEffect(() => { // Re-resolve if the OS preference changes while "system" is selected.
applyCustomThemeCss(activeCustomTheme) const media = window.matchMedia?.('(prefers-color-scheme: dark)')
}, [activeCustomTheme]) const onChange = () => applyTheme(settings.theme)
media?.addEventListener('change', onChange)
return () => media?.removeEventListener('change', onChange)
}, [settings])
const guarded = useCallback((action: () => Promise<void>) => { const guarded = useCallback((action: () => Promise<void>) => {
return async () => { return async () => {
@@ -121,20 +94,10 @@ export function useSettingsPanel() {
} }
}, []) }, [])
const setColorScheme = useCallback( const setTheme = useCallback(
(colorScheme: ColorScheme) => (theme: Theme) =>
guarded(async () => { guarded(async () => {
// Selecting a built-in scheme always deselects any custom theme — the two are mutually exclusive (see `applyColorScheme`). const next = await updateSettings({ theme })
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],
@@ -158,40 +121,6 @@ export function useSettingsPanel() {
[guarded], [guarded],
) )
// Every entry is always a selectable style, on equal footing with the built-in colour schemes
// (ticket 020 unified the data model; ticket 047 unified the UI), so there's a single import path.
const importCssEntry = useCallback(
(filename: string, content: string) =>
guarded(async () => {
await importCss(filename, content)
await refreshCss()
})(),
[guarded, refreshCss],
)
const linkCssEntry = useCallback(
(pathOrUrl: string) =>
guarded(async () => {
await linkCss(pathOrUrl)
await refreshCss()
})(),
[guarded, refreshCss],
)
const removeCssEntry = useCallback(
(id: string) =>
guarded(async () => {
await deleteCssEntry(id)
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, settings],
)
const addProfile = useCallback( const addProfile = useCallback(
(name: string) => (name: string) =>
guarded(async () => { guarded(async () => {
@@ -221,17 +150,12 @@ export function useSettingsPanel() {
return { return {
settings, settings,
cssEntries, themes,
activeCustomTheme,
profiles, profiles,
error, error,
setColorScheme, setTheme,
setCustomTheme,
setMaxLogs, setMaxLogs,
setNtfySink, setNtfySink,
importCssEntry,
linkCssEntry,
removeCssEntry,
addProfile, addProfile,
removeProfile, removeProfile,
switchProfile, switchProfile,