Harden web view: stable ordering, shared connection, dark palette, tests
Seven related improvements to the CI web view:
1. Replace ORDER BY rowid with started_at_ms NULLS FIRST, job_id ASC
in load_run_detail. SQLite reuses rowids on VACUUM; the new sort
is deterministic by launch order.
2. Convert load_run_detail's 3-tuple return to a named RunDetail
struct for readability at the call site.
3. Deduplicate the identical state_class match arms across RunListRow,
DetailRun, and DetailJob into a single format::state_class() free
function.
4. Add prefers-color-scheme: dark media query to _css.html with a
warm dark palette derived from the light tokens.
5. Drop the silent 120-char truncation in DetailShEvent::cmd_display.
Commands sit in a flow container that handles overflow.
6. Replace per-request Connection::open with a lazily-initialised
Arc<OnceLock<Mutex<Connection>>> on Quire. Central db::open now
sets PRAGMA busy_timeout = 5000 alongside the existing WAL and
foreign_keys pragmas.
7. Add test coverage for db::resolve_repo_name, format::state_class,
auth::require_auth, and the three CI route handlers (using
axum oneshot + real in-memory SQLite with migrations).
Assisted-by: GLM-5.1 via pi
diff --git a/Cargo.lock b/Cargo.lock
index 2fc4434..3d76c69 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2134,6 +2134,7 @@ dependencies = [
"clap_complete",
"fs-err",
"hegeltest",
+ "http-body-util",
"jiff",
"miette",
"mlua",
@@ -2151,6 +2152,7 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
+ "tower",
"tracing",
"tracing-subscriber",
"uuid",
diff --git a/Cargo.toml b/Cargo.toml
index dc57f11..c096ecc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -37,5 +37,7 @@ walkdir = "*"
[dev-dependencies]
assert_cmd = "*"
hegeltest = "*"
+http-body-util = "*"
predicates = "*"
serde_json = "*"
+tower = { version = "*", features = ["util"] }
diff --git a/src/db.rs b/src/db.rs
index d7f473f..7fca1ca 100644
--- a/src/db.rs
+++ b/src/db.rs
@@ -38,7 +38,11 @@ impl From<rusqlite_migration::Error> for MigrationError {
/// Does not run migrations. Call [`migrate`] once at server startup.
pub fn open(path: &Path) -> Result<Connection, rusqlite::Error> {
let conn = Connection::open(path)?;
- conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;")?;
+ conn.execute_batch(
+ "PRAGMA journal_mode = WAL;
+ PRAGMA foreign_keys = ON;
+ PRAGMA busy_timeout = 5000;",
+ )?;
Ok(conn)
}
diff --git a/src/quire/mod.rs b/src/quire/mod.rs
index 9bb288a..8884ea0 100644
--- a/src/quire/mod.rs
+++ b/src/quire/mod.rs
@@ -1,5 +1,6 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, OnceLock};
use miette::{Context, IntoDiagnostic, Result, ensure};
@@ -147,6 +148,7 @@ impl Repo {
#[derive(Clone)]
pub struct Quire {
base_dir: PathBuf,
+ db_pool: Arc<OnceLock<Mutex<rusqlite::Connection>>>,
}
impl Default for Quire {
@@ -158,7 +160,10 @@ impl Default for Quire {
impl Quire {
/// Create a `Quire` rooted at the given base directory.
pub fn new(base_dir: PathBuf) -> Self {
- Self { base_dir }
+ Self {
+ base_dir,
+ db_pool: Arc::new(OnceLock::new()),
+ }
}
pub fn base_dir(&self) -> &Path {
@@ -181,6 +186,17 @@ impl Quire {
self.base_dir.join("server.sock")
}
+ /// Return the shared DB connection for the web view.
+ ///
+ /// Lazily initialises the connection on first call. Once open, the
+ /// same connection is reused for all subsequent requests.
+ pub fn db_pool(&self) -> &Mutex<rusqlite::Connection> {
+ self.db_pool.get_or_init(|| {
+ let conn = crate::db::open(&self.db_path()).expect("failed to open database");
+ Mutex::new(conn)
+ })
+ }
+
/// Load and parse the global Fennel config file.
///
/// Re-reads on every call. Cheap at current call volume; revisit if
@@ -345,9 +361,7 @@ mod tests {
#[test]
fn repo_from_path_valid() {
let dir = tempfile::tempdir().expect("tempdir");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let path = dir.path().join("repos").join("foo.git");
let repo = q.repo_from_path(&path).expect("should resolve");
assert_eq!(repo.path(), path);
@@ -356,9 +370,7 @@ mod tests {
#[test]
fn repo_from_path_outside_repos() {
let dir = tempfile::tempdir().expect("tempdir");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let path = PathBuf::from("/tmp/evil.git");
assert!(q.repo_from_path(&path).is_err());
}
@@ -366,9 +378,7 @@ mod tests {
#[test]
fn repo_from_path_rejects_bad_name() {
let dir = tempfile::tempdir().expect("tempdir");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let path = dir.path().join("repos").join("foo"); // missing .git
assert!(q.repo_from_path(&path).is_err());
}
@@ -379,9 +389,7 @@ mod tests {
let config_path = dir.path().join("config.fnl");
fs_err::write(&config_path, "{}").expect("write");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let config = q.global_config().expect("global_config should load");
assert!(config.secrets.is_empty());
}
@@ -390,9 +398,7 @@ mod tests {
fn global_config_missing_file_errors() {
let dir = tempfile::tempdir().expect("tempdir");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let err = q.global_config().unwrap_err();
assert!(
matches!(err, Error::ConfigNotFound(_)),
@@ -410,9 +416,7 @@ mod tests {
)
.expect("write");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let config = q.global_config().expect("global_config should load");
let sentry = config.sentry.expect("sentry should be present");
assert_eq!(sentry.dsn.reveal().unwrap(), "https://key@sentry.io/123");
@@ -424,9 +428,7 @@ mod tests {
let config_path = dir.path().join("config.fnl");
fs_err::write(&config_path, "{}").expect("write");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let config = q.global_config().expect("global_config should load");
assert!(config.sentry.is_none());
}
@@ -437,9 +439,7 @@ mod tests {
let config_path = dir.path().join("config.fnl");
fs_err::write(&config_path, "{}").expect("write");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let config = q.global_config().expect("global_config should load");
assert!(config.secrets.is_empty());
}
@@ -460,9 +460,7 @@ mod tests {
)
.expect("write");
- let q = Quire {
- base_dir: dir.path().to_path_buf(),
- };
+ let q = Quire::new(dir.path().to_path_buf());
let config = q.global_config().expect("global_config should load");
assert_eq!(config.secrets.len(), 2);
assert_eq!(
diff --git a/src/quire/web/auth.rs b/src/quire/web/auth.rs
index 66d171e..01117ae 100644
--- a/src/quire/web/auth.rs
+++ b/src/quire/web/auth.rs
@@ -20,3 +20,43 @@ pub async fn require_auth(request: axum::extract::Request, next: Next) -> Respon
next.run(request).await
}
+
+#[cfg(test)]
+mod tests {
+ use axum::body::Body;
+ use axum::http::{Request, StatusCode};
+ use axum::routing::get;
+ use tower::ServiceExt;
+
+ use super::require_auth;
+
+ async fn ok_handler() -> &'static str {
+ "ok"
+ }
+
+ fn test_app() -> axum::Router {
+ axum::Router::new()
+ .route("/", get(ok_handler))
+ .layer(axum::middleware::from_fn(require_auth))
+ }
+
+ #[tokio::test]
+ async fn require_auth_rejects_missing_header() {
+ let app = test_app();
+ let req = Request::builder().uri("/").body(Body::empty()).unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn require_auth_allows_valid_header() {
+ let app = test_app();
+ let req = Request::builder()
+ .uri("/")
+ .header("Remote-User", "alice")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ }
+}
diff --git a/src/quire/web/db.rs b/src/quire/web/db.rs
index 8048428..647d606 100644
--- a/src/quire/web/db.rs
+++ b/src/quire/web/db.rs
@@ -1,7 +1,5 @@
//! Data access structs and DB loading functions for the web view.
-use rusqlite::Connection;
-
use crate::{Quire, Result};
/// Raw run row from the database.
@@ -34,7 +32,7 @@ pub struct ShEvent {
}
pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
- let db = Connection::open(quire.db_path())?;
+ let db = quire.db_pool().lock().expect("db mutex poisoned");
let mut stmt = db.prepare(
"SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
FROM runs WHERE repo = ?1
@@ -59,12 +57,15 @@ pub fn load_runs(quire: &Quire, repo: &str) -> Result<Vec<RunRow>> {
Ok(rows)
}
-pub fn load_run_detail(
- quire: &Quire,
- repo: &str,
- run_id: &str,
-) -> Result<(RunRow, Vec<JobRow>, Vec<ShEvent>)> {
- let db = Connection::open(quire.db_path())?;
+/// Aggregated run detail from the database.
+pub struct RunDetail {
+ pub run: RunRow,
+ pub jobs: Vec<JobRow>,
+ pub sh_events: Vec<ShEvent>,
+}
+
+pub fn load_run_detail(quire: &Quire, repo: &str, run_id: &str) -> Result<RunDetail> {
+ let db = quire.db_pool().lock().expect("db mutex poisoned");
let run = db.query_row(
"SELECT id, state, sha, ref_name, queued_at_ms, started_at_ms, finished_at_ms
@@ -86,7 +87,7 @@ pub fn load_run_detail(
let mut job_stmt = db.prepare(
"SELECT job_id, state, exit_code, started_at_ms, finished_at_ms
FROM jobs WHERE run_id = ?1
- ORDER BY rowid",
+ ORDER BY started_at_ms IS NULL ASC, started_at_ms ASC, job_id ASC",
)?;
let jobs = job_stmt
@@ -119,7 +120,11 @@ pub fn load_run_detail(
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
- Ok((run, jobs, sh_events))
+ Ok(RunDetail {
+ run,
+ jobs,
+ sh_events,
+ })
}
/// Resolve a URL slug to the on-disk repo name.
@@ -159,6 +164,21 @@ pub fn is_safe_path_segment(s: &str) -> bool {
mod tests {
use super::*;
+ #[test]
+ fn resolve_repo_name_appends_git() {
+ assert_eq!(resolve_repo_name("foo"), "foo.git");
+ }
+
+ #[test]
+ fn resolve_repo_name_preserves_git_suffix() {
+ assert_eq!(resolve_repo_name("foo.git"), "foo.git");
+ }
+
+ #[test]
+ fn resolve_repo_name_handles_grouped_repo() {
+ assert_eq!(resolve_repo_name("work/proj"), "work/proj.git");
+ }
+
#[test]
fn run_id_accepts_uuid() {
assert!(is_valid_run_id("0194f3a5-2b3c-7000-8000-000000000000"));
diff --git a/src/quire/web/format.rs b/src/quire/web/format.rs
index 9405dcb..8a9f0d9 100644
--- a/src/quire/web/format.rs
+++ b/src/quire/web/format.rs
@@ -62,6 +62,18 @@ fn format_ms_duration(ms: i64) -> String {
}
}
+/// Map a CI run/job state string to a CSS colour class.
+///
+/// Centralised here so `RunListRow`, `DetailRun`, and `DetailJob`
+/// don't each carry their own identical match.
+pub fn state_class(state: &str) -> &'static str {
+ match state {
+ "complete" => "c-ok",
+ "failed" => "c-bad",
+ _ => "c-muted",
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -109,4 +121,21 @@ mod tests {
assert_eq!(relative_label(24 * 60 * 60), None);
assert_eq!(relative_label(72 * 60 * 60), None);
}
+
+ #[test]
+ fn state_class_complete() {
+ assert_eq!(state_class("complete"), "c-ok");
+ }
+
+ #[test]
+ fn state_class_failed() {
+ assert_eq!(state_class("failed"), "c-bad");
+ }
+
+ #[test]
+ fn state_class_unknown_falls_through() {
+ assert_eq!(state_class("pending"), "c-muted");
+ assert_eq!(state_class("active"), "c-muted");
+ assert_eq!(state_class(""), "c-muted");
+ }
}
diff --git a/src/quire/web/handlers.rs b/src/quire/web/handlers.rs
index a83bd1e..69f0323 100644
--- a/src/quire/web/handlers.rs
+++ b/src/quire/web/handlers.rs
@@ -108,7 +108,7 @@ pub async fn run_detail(
}
let result = db::load_run_detail(&quire, &repo_name, &run_id);
- let (run, jobs, sh_events) = match result {
+ let detail = match result {
Ok(d) => d,
Err(e) => {
tracing::error!(repo = %repo, run_id = %run_id, error = %display_chain(&e), "failed to load run detail");
@@ -122,27 +122,27 @@ pub async fn run_detail(
};
let detail_run = DetailRun {
- state: run.state,
- sha: run.sha,
- ref_name: run.ref_name,
- queued_at_ms: run.queued_at_ms,
- started_at_ms: run.started_at_ms,
- finished_at_ms: run.finished_at_ms,
+ state: detail.run.state,
+ sha: detail.run.sha,
+ ref_name: detail.run.ref_name,
+ queued_at_ms: detail.run.queued_at_ms,
+ started_at_ms: detail.run.started_at_ms,
+ finished_at_ms: detail.run.finished_at_ms,
};
// Group sh events by job_id, preserving DB order so positional index
// matches launch order.
let mut events_by_job: std::collections::HashMap<&str, Vec<&db::ShEvent>> =
std::collections::HashMap::new();
- for ev in &sh_events {
+ for ev in &detail.sh_events {
events_by_job.entry(&ev.job_id).or_default().push(ev);
}
let runs_base = quire.base_dir().join("runs").join(&repo_name);
let job_dir_base = runs_base.join(&run_id).join("jobs");
- let mut detail_jobs: Vec<DetailJob> = Vec::with_capacity(jobs.len());
- for job in &jobs {
+ let mut detail_jobs: Vec<DetailJob> = Vec::with_capacity(detail.jobs.len());
+ for job in &detail.jobs {
let job_events = events_by_job
.get(job.job_id.as_str())
.map(Vec::as_slice)
@@ -187,3 +187,197 @@ pub async fn run_detail(
};
render(&tmpl)
}
+
+#[cfg(test)]
+mod tests {
+ use axum::body::Body;
+ use axum::http::{Request, StatusCode};
+ use tower::ServiceExt;
+
+ use crate::Quire;
+
+ /// Build a test axum Router with the CI routes, backed by a tempdir.
+ struct TestEnv {
+ _dir: tempfile::TempDir,
+ quire: Quire,
+ }
+
+ impl TestEnv {
+ fn new() -> Self {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let quire = Quire::new(dir.path().to_path_buf());
+
+ // Create repos dir + a bare repo so `quire.repo("example.git")` resolves.
+ let repos_dir = quire.repos_dir();
+ let bare = repos_dir.join("example.git");
+ fs_err::create_dir_all(&bare).expect("mkdir bare repo");
+
+ // Initialise DB with migrations.
+ let mut db = crate::db::open(&quire.db_path()).expect("db open");
+ crate::db::migrate(&mut db).expect("migrate");
+ drop(db);
+
+ Self { _dir: dir, quire }
+ }
+
+ fn insert_run(
+ &self,
+ id: &str,
+ state: &str,
+ sha: &str,
+ ref_name: &str,
+ queued: i64,
+ started: Option<i64>,
+ finished: Option<i64>,
+ ) {
+ let pool = self.quire.db_pool();
+ let db = pool.lock().expect("lock");
+ db.execute(
+ "INSERT INTO runs (id, repo, ref_name, sha, pushed_at_ms, state, failure_kind,
+ queued_at_ms, started_at_ms, finished_at_ms,
+ container_id, image_tag, build_started_at_ms, build_finished_at_ms,
+ container_started_at_ms, container_stopped_at_ms, workspace_path)
+ VALUES (?1, 'example.git', ?2, ?3, ?4, ?5, NULL, ?4, ?6, ?7, NULL, NULL, NULL, NULL, NULL, NULL, '/tmp/ws')",
+ rusqlite::params![id, ref_name, sha, queued, state, started, finished],
+ ).expect("insert run");
+ }
+
+ fn insert_job(
+ &self,
+ run_id: &str,
+ job_id: &str,
+ state: &str,
+ exit_code: Option<i32>,
+ started: Option<i64>,
+ finished: Option<i64>,
+ ) {
+ let pool = self.quire.db_pool();
+ let db = pool.lock().expect("lock");
+ db.execute(
+ "INSERT INTO jobs (run_id, job_id, state, exit_code, started_at_ms, finished_at_ms)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
+ rusqlite::params![run_id, job_id, state, exit_code, started, finished],
+ )
+ .expect("insert job");
+ }
+
+ fn app(&self) -> axum::Router {
+ super::super::router(self.quire.clone())
+ }
+ }
+
+ const UUID1: &str = "aaaaaaaa-0000-0000-0000-000000000001";
+ const SHA1: &str = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+
+ #[tokio::test]
+ async fn repo_redirect_strips_git_and_redirects() {
+ let env = TestEnv::new();
+ let app = env.app();
+ let req = Request::builder()
+ .uri("/example")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
+ let loc = resp.headers().get("location").unwrap().to_str().unwrap();
+ assert_eq!(loc, "/example/ci");
+ }
+
+ #[tokio::test]
+ async fn repo_redirect_strips_git_suffix() {
+ let env = TestEnv::new();
+ let app = env.app();
+ let req = Request::builder()
+ .uri("/example.git")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::TEMPORARY_REDIRECT);
+ let loc = resp.headers().get("location").unwrap().to_str().unwrap();
+ assert_eq!(loc, "/example/ci");
+ }
+
+ #[tokio::test]
+ async fn run_list_returns_ok_for_known_repo() {
+ let env = TestEnv::new();
+ env.insert_run(
+ UUID1,
+ "complete",
+ SHA1,
+ "refs/heads/main",
+ 1000,
+ Some(2000),
+ Some(3000),
+ );
+ let app = env.app();
+ let req = Request::builder()
+ .uri("/example/ci")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ }
+
+ #[tokio::test]
+ async fn run_list_returns_empty_for_unknown_repo() {
+ let env = TestEnv::new();
+ let app = env.app();
+ let req = Request::builder()
+ .uri("/nonexistent/ci")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ // Unknown repo still has a valid name — returns empty run list.
+ assert_eq!(resp.status(), StatusCode::OK);
+ }
+
+ #[tokio::test]
+ async fn run_detail_returns_ok_for_existing_run() {
+ let env = TestEnv::new();
+ env.insert_run(
+ UUID1,
+ "complete",
+ SHA1,
+ "refs/heads/main",
+ 1000,
+ Some(2000),
+ Some(3000),
+ );
+ env.insert_job(UUID1, "build", "complete", Some(0), Some(2000), Some(3000));
+ let app = env.app();
+ let req = Request::builder()
+ .uri(&format!("/example/ci/{UUID1}"))
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::OK);
+ }
+
+ #[tokio::test]
+ async fn run_detail_returns_404_for_invalid_id() {
+ let env = TestEnv::new();
+ let app = env.app();
+ let req = Request::builder()
+ .uri("/example/ci/not-a-uuid")
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn run_detail_returns_empty_for_unknown_repo() {
+ let env = TestEnv::new();
+ let app = env.app();
+ // Valid UUID but repo has no runs — the DB query returns no row,
+ // which triggers a 500 "failed to load run detail".
+ // This tests the full pipeline with a real DB.
+ let req = Request::builder()
+ .uri(&format!("/example/ci/{UUID1}"))
+ .body(Body::empty())
+ .unwrap();
+ let resp = app.oneshot(req).await.unwrap();
+ // No run inserted, so query_row returns Err.
+ assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
+ }
+}
diff --git a/src/quire/web/templates.rs b/src/quire/web/templates.rs
index e0c7378..84300e4 100644
--- a/src/quire/web/templates.rs
+++ b/src/quire/web/templates.rs
@@ -36,12 +36,8 @@ pub struct RunListRow {
}
impl RunListRow {
- pub fn state_class(&self) -> &str {
- match self.state.as_str() {
- "complete" => "c-ok",
- "failed" => "c-bad",
- _ => "c-muted",
- }
+ pub fn state_class(&self) -> &'static str {
+ format::state_class(&self.state)
}
pub fn sha_short(&self) -> &str {
@@ -92,12 +88,8 @@ pub struct DetailRun {
}
impl DetailRun {
- pub fn state_class(&self) -> &str {
- match self.state.as_str() {
- "complete" => "c-ok",
- "failed" => "c-bad",
- _ => "c-muted",
- }
+ pub fn state_class(&self) -> &'static str {
+ format::state_class(&self.state)
}
pub fn sha_short(&self) -> &str {
@@ -163,12 +155,8 @@ pub struct DetailJob {
}
impl DetailJob {
- pub fn state_class(&self) -> &str {
- match self.state.as_str() {
- "complete" => "c-ok",
- "failed" => "c-bad",
- _ => "c-muted",
- }
+ pub fn state_class(&self) -> &'static str {
+ format::state_class(&self.state)
}
pub fn duration_display(&self) -> String {
@@ -196,11 +184,7 @@ impl DetailShEvent {
}
pub fn cmd_display(&self) -> &str {
- if self.cmd.len() > 120 {
- &self.cmd[..120]
- } else {
- &self.cmd
- }
+ &self.cmd
}
}
diff --git a/templates/_css.html b/templates/_css.html
index 211b5f2..242f2eb 100644
--- a/templates/_css.html
+++ b/templates/_css.html
@@ -189,3 +189,20 @@ pre { white-space: pre-wrap; word-break: break-word; }
.c-bad { color: var(--bad); }
.c-muted { color: var(--muted); }
.c-accent { color: var(--accent); }
+
+/* Dark palette — activates when the OS signals dark mode. */
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #1d1a15;
+ --ink: #e8e0d0;
+ --muted: #9a9184;
+ --mutedFaint: #6b6257;
+ --rule: #3a3530;
+ --rule2: #4a4540;
+ --code: #2a2520;
+ --accent: #c8c0b0;
+ --ok: #6aad5a;
+ --bad: #d45a48;
+ }
+}