Remove uploading css functionality. Themes are hard-coded. Change the code
This commit is contained in:
@@ -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
@@ -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
@@ -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(¶ms).map_err(|err| mcp_error("failed to encode params", err))?;
|
|
||||||
json_result(self.send_json(reqwest::Method::POST, "/api/settings/css", Some(&body)).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(description = "Link an external CSS file or URL as a custom theme, instead of importing its content.")]
|
|
||||||
async fn link_css(
|
|
||||||
&self,
|
|
||||||
Parameters(params): Parameters<LinkCssParams>,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
let body = serde_json::to_value(¶ms).map_err(|err| mcp_error("failed to encode params", err))?;
|
|
||||||
json_result(self.send_json(reqwest::Method::POST, "/api/settings/css/link", Some(&body)).await?)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(description = "Remove a custom CSS entry by id.")]
|
|
||||||
async fn remove_css(
|
|
||||||
&self,
|
|
||||||
Parameters(params): Parameters<CssIdParams>,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
json_result(
|
|
||||||
self.send_json(
|
|
||||||
reqwest::Method::DELETE,
|
|
||||||
&format!("/api/settings/css/{}", urlencoding_path(¶ms.id)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tool(description = "Read the raw content of an imported CSS entry by id.")]
|
|
||||||
async fn get_css_content(
|
|
||||||
&self,
|
|
||||||
Parameters(params): Parameters<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(¶ms.id))))
|
.get(self.url(&format!("/api/themes/{}", urlencoding_path(¶ms.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(),
|
||||||
|
|||||||
+29
-93
@@ -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 list_css(State(state): State<ApiState>) -> impl IntoResponse {
|
async fn get_theme_content(AxumPath(name): AxumPath<String>) -> impl IntoResponse {
|
||||||
let settings = state.settings.lock().unwrap();
|
// Themes are just filenames within a flat embedded dir, not filesystem paths, so reject
|
||||||
let entries: Vec<CssEntryDto> = settings.css_entries.iter().map(CssEntryDto::from).collect();
|
// anything that isn't a plain name to rule out path traversal.
|
||||||
Json(entries).into_response()
|
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")) {
|
||||||
async fn import_css(
|
Some(file) => {
|
||||||
State(state): State<ApiState>,
|
let bytes = file.read_bytes().unwrap_or_default();
|
||||||
Json(body): Json<ImportCssRequest>,
|
([(header::CONTENT_TYPE, "text/css; charset=utf-8")], bytes).into_response()
|
||||||
) -> 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(),
|
None => StatusCode::NOT_FOUND.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
@@ -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();
|
||||||
|
|||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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%);
|
||||||
|
}
|
||||||
@@ -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) => (
|
|
||||||
<div key={scheme} className="flex items-center">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => panel.setColorScheme(scheme)}
|
onClick={() => panel.setTheme('system')}
|
||||||
className={`rounded-l border px-2 py-1 text-xs capitalize ${
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
!panel.activeCustomTheme && panel.settings?.color_scheme === scheme
|
panel.settings?.theme === 'system'
|
||||||
? 'border-border-strong bg-surface-2 text-text'
|
? 'border-border-strong bg-surface-2 text-text'
|
||||||
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{schemeLabel(scheme)}
|
System
|
||||||
</button>
|
</button>
|
||||||
{scheme !== 'system' && (
|
{panel.themes.map((theme) => (
|
||||||
<button
|
<button
|
||||||
|
key={theme}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => downloadBuiltinTheme(scheme)}
|
onClick={() => panel.setTheme(theme)}
|
||||||
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"
|
className={`rounded border px-2 py-1 text-xs capitalize ${
|
||||||
aria-label={`Download ${schemeLabel(scheme)} theme`}
|
panel.settings?.theme === theme
|
||||||
title="Download as .css"
|
? 'border-border-strong bg-surface-2 text-text'
|
||||||
>
|
: 'border-border-soft text-text-muted hover:bg-surface-2'
|
||||||
↓
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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}
|
{themeLabel(theme)}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
+9
-411
@@ -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
@@ -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[]> {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user