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
change ksqqswvrqltrvuzmotyrqrrkuyssllsn
commit 6076e650118649e0c0fb9a1803e9d8b905da90f8
author Alpha Chen <alpha@kejadlen.dev>
date
parent wumntxmo
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;
+  }
+}