web: extract CSS to static file with Tracker-inspired design tokens
- Create static/style.css with CSS custom properties for colors, typography,
  and spacing following ketchup's utopia fluid type scale pattern
- Tracker palette: header #3c3d3f, panels #eeeddf, text #333, accent #3d6098
- State colors: in-progress #629a3e, queued #3d6098, done #8ba83b, icebox #8a8878
- Row tints at 12% opacity, 7px row padding, 1px gap, 2px border-radius
- System sans-serif at 13px for titles, SF Mono/monospace at 11px for keys
- Serve CSS via axum route using include_str! for compile-time embedding
- Replace inline <style> block with <link> to /static/style.css
change lslkvtomrukulmrsopnzrnqqnsnsrwrp
commit 26aa162367fd6b6356fef73b48a0e7f6c5756734
author Alpha Chen <alpha@kejadlen.dev>
date
parent xwxxkpnn
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 200ca08..88f1d06 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -1,5 +1,6 @@
 use axum::extract::State;
-use axum::response::Html;
+use axum::http::header;
+use axum::response::{Html, IntoResponse};
 use axum::{Router, routing::get};
 use ranger::key;
 use ranger::models::Task;
@@ -9,6 +10,9 @@ use sqlx::SqlitePool;
 use std::net::SocketAddr;
 use tokio::net::TcpListener;
 
+/// Static CSS embedded at compile time from `static/style.css`.
+const STYLE_CSS: &str = include_str!("../../../../static/style.css");
+
 #[derive(Clone)]
 struct AppState {
     pool: SqlitePool,
@@ -21,7 +25,10 @@ pub async fn run(pool: &SqlitePool, port: u16, backlog_name: String) -> color_ey
         backlog_name,
     };
 
-    let app = Router::new().route("/", get(index)).with_state(state);
+    let app = Router::new()
+        .route("/", get(index))
+        .route("/static/style.css", get(serve_css))
+        .with_state(state);
 
     let addr = SocketAddr::from(([0, 0, 0, 0], port));
     eprintln!("Listening on http://{addr}");
@@ -32,6 +39,10 @@ pub async fn run(pool: &SqlitePool, port: u16, backlog_name: String) -> color_ey
     Ok(())
 }
 
+async fn serve_css() -> impl IntoResponse {
+    ([(header::CONTENT_TYPE, "text/css")], STYLE_CSS)
+}
+
 async fn index(State(state): State<AppState>) -> Html<String> {
     match render_board(&state).await {
         Ok(html) => Html(html),
@@ -95,140 +106,7 @@ async fn render_board(state: &AppState) -> color_eyre::Result<String> {
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>ranger › {backlog_name}</title>
-<style>
-  :root {{
-    --bg: #0e0e10;
-    --surface: #1a1a1e;
-    --border: #2a2a2e;
-    --text: #e0e0e0;
-    --text-dim: #888;
-    --accent: #7c6fe0;
-    --green: #4caf50;
-    --yellow: #e0b44c;
-    --blue: #5b9fe0;
-  }}
-  *, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
-  body {{
-    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
-    background: var(--bg);
-    color: var(--text);
-    line-height: 1.5;
-  }}
-  header {{
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    padding: 12px 24px;
-    border-bottom: 1px solid var(--border);
-    background: var(--surface);
-  }}
-  header h1 {{
-    font-size: 16px;
-    font-weight: 600;
-  }}
-  header h1 span.sep {{
-    color: var(--text-dim);
-    margin: 0 6px;
-  }}
-  header .counts {{
-    font-size: 13px;
-    color: var(--text-dim);
-  }}
-  .board {{
-    display: grid;
-    grid-template-columns: 1fr 280px 300px;
-    height: calc(100vh - 49px);
-  }}
-  .panel {{
-    border-right: 1px solid var(--border);
-    overflow-y: auto;
-    padding: 16px;
-  }}
-  .panel:last-child {{
-    border-right: none;
-  }}
-  .panel-header {{
-    display: flex;
-    align-items: center;
-    justify-content: space-between;
-    margin-bottom: 12px;
-    padding-bottom: 8px;
-    border-bottom: 1px solid var(--border);
-  }}
-  .panel-header h2 {{
-    font-size: 12px;
-    font-weight: 600;
-    text-transform: uppercase;
-    letter-spacing: 0.05em;
-    color: var(--text-dim);
-  }}
-  .panel-header .count {{
-    font-size: 11px;
-    color: var(--text-dim);
-    background: var(--border);
-    padding: 1px 6px;
-    border-radius: 8px;
-  }}
-  .section-label {{
-    font-size: 11px;
-    font-weight: 600;
-    text-transform: uppercase;
-    letter-spacing: 0.05em;
-    color: var(--accent);
-    margin: 16px 0 8px 0;
-  }}
-  .section-label:first-child {{
-    margin-top: 0;
-  }}
-  .task {{
-    background: var(--surface);
-    border: 1px solid var(--border);
-    border-radius: 6px;
-    padding: 10px 12px;
-    margin-bottom: 6px;
-  }}
-  .task .task-header {{
-    display: flex;
-    align-items: baseline;
-    gap: 8px;
-  }}
-  .task .key {{
-    font-family: "SF Mono", "Fira Code", monospace;
-    font-size: 11px;
-    color: var(--accent);
-    flex-shrink: 0;
-  }}
-  .task .title {{
-    font-size: 14px;
-    font-weight: 500;
-  }}
-  .task .desc {{
-    font-size: 12px;
-    color: var(--text-dim);
-    margin-top: 4px;
-    white-space: nowrap;
-    overflow: hidden;
-    text-overflow: ellipsis;
-  }}
-  .task .subtask-indicator {{
-    font-size: 11px;
-    color: var(--text-dim);
-    margin-top: 4px;
-  }}
-  .state-in_progress .task {{ border-left: 3px solid var(--yellow); }}
-  .state-queued .task {{ border-left: 3px solid var(--blue); }}
-  .state-done .task {{
-    border-left: 3px solid var(--green);
-    opacity: 0.7;
-  }}
-  .empty {{
-    font-size: 13px;
-    color: var(--text-dim);
-    font-style: italic;
-    padding: 20px 0;
-    text-align: center;
-  }}
-</style>
+<link rel="stylesheet" href="/static/style.css">
 </head>
 <body>
 <header>
@@ -261,8 +139,8 @@ fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> String
         html.push_str(r#"<div class="empty">No active tasks</div>"#);
     } else {
         if !in_progress.is_empty() {
-            html.push_str(r#"<div class="section-label">In Progress</div>"#);
-            html.push_str(r#"<div class="state-in_progress">"#);
+            html.push_str(r#"<div class="section-label section-label-in-progress">In Progress</div>"#);
+            html.push_str(r#"<div class="state-in-progress">"#);
             for task in in_progress {
                 html.push_str(&render_task(task));
             }
@@ -270,7 +148,7 @@ fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> String
         }
 
         if !queued.is_empty() {
-            html.push_str(r#"<div class="section-label">Queued</div>"#);
+            html.push_str(r#"<div class="section-label section-label-queued">Queued</div>"#);
             html.push_str(r#"<div class="state-queued">"#);
             for task in queued {
                 html.push_str(&render_task(task));
@@ -291,6 +169,7 @@ fn render_column_panel(name: &str, tasks: &[TaskView]) -> String {
     };
     let state_class = match name {
         "done" => "state-done",
+        "icebox" => "state-icebox",
         _ => "",
     };
     let count = tasks.len();
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..77e771b
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,227 @@
+/* ──────────────────────────────────────────────
+   Ranger — Design Tokens & Tracker-Inspired Styling
+   ────────────────────────────────────────────── */
+
+/* === Utopia fluid type scale ===
+   https://utopia.fyi — clamp()-based fluid sizing
+   Base: 13px @ 320 → 13px @ 1280 (flat for body)
+   Keys: 11px fixed (monospace, no scaling needed)
+   ────────────────────────────────────────────── */
+
+:root {
+  /* ── Color tokens: surfaces ── */
+  --color-bg-header:   #3c3d3f;
+  --color-bg-panel:    #eeeddf;
+  --color-border:      #d5d3c5;
+
+  /* ── Color tokens: text ── */
+  --color-text:        #333;
+  --color-text-muted:  #7a7868;
+  --color-text-header: #fff;
+
+  /* ── Color tokens: accent ── */
+  --color-accent:      #3d6098;
+
+  /* ── Color tokens: state ── */
+  --color-in-progress: #629a3e;
+  --color-queued:      #3d6098;
+  --color-done:        #8ba83b;
+  --color-icebox:      #8a8878;
+
+  /* ── State row tints (12 % opacity) ── */
+  --tint-in-progress:  rgba(98, 154, 62, 0.12);
+  --tint-queued:       rgba(61, 96, 152, 0.12);
+  --tint-done:         rgba(139, 168, 59, 0.12);
+  --tint-icebox:       rgba(138, 136, 120, 0.12);
+
+  /* ── Typography ── */
+  --font-sans:  -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
+                "Helvetica Neue", Arial, sans-serif;
+  --font-mono:  "SF Mono", "Fira Code", "Cascadia Code", Menlo, Consolas,
+                monospace;
+
+  /* Fluid type scale (utopia-style clamp, flat at this range) */
+  --step-0:    clamp(0.8125rem, 0.8125rem + 0vi, 0.8125rem);  /* 13px */
+  --step-mono: 0.6875rem;                                       /* 11px */
+
+  /* ── Spacing ── */
+  --row-padding-y:  7px;
+  --row-gap:        1px;
+  --panel-padding:  20px;
+  --radius:         2px;
+}
+
+
+/* === Reset === */
+*, *::before, *::after {
+  box-sizing: border-box;
+  margin: 0;
+  padding: 0;
+}
+
+
+/* === Body === */
+body {
+  font-family: var(--font-sans);
+  font-size: var(--step-0);
+  line-height: 1.5;
+  color: var(--color-text);
+  background: var(--color-bg-panel);
+}
+
+
+/* === Header === */
+header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 24px;
+  background: var(--color-bg-header);
+  color: var(--color-text-header);
+}
+
+header h1 {
+  font-size: var(--step-0);
+  font-weight: 600;
+}
+
+header h1 .sep {
+  color: var(--color-text-muted);
+  margin: 0 6px;
+}
+
+header .counts {
+  font-size: var(--step-mono);
+  color: rgba(255, 255, 255, 0.6);
+}
+
+
+/* === Board layout === */
+.board {
+  display: grid;
+  grid-template-columns: 1fr 280px 300px;
+  height: calc(100vh - 41px);          /* header height */
+}
+
+
+/* === Panels === */
+.panel {
+  overflow-y: auto;
+  padding: var(--panel-padding);
+  border-right: 1px solid var(--color-border);
+  background: var(--color-bg-panel);
+}
+
+.panel:last-child {
+  border-right: none;
+}
+
+.panel-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px solid var(--color-border);
+}
+
+.panel-header h2 {
+  font-size: var(--step-mono);
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  color: var(--color-text-muted);
+}
+
+.panel-header .count {
+  font-size: var(--step-mono);
+  color: var(--color-text-muted);
+  background: var(--color-border);
+  padding: 1px 6px;
+  border-radius: 8px;
+}
+
+
+/* === Section labels === */
+.section-label {
+  font-size: var(--step-mono);
+  font-weight: 600;
+  text-transform: uppercase;
+  letter-spacing: 0.05em;
+  margin: 16px 0 8px 0;
+}
+
+.section-label:first-child {
+  margin-top: 0;
+}
+
+.section-label-in-progress { color: var(--color-in-progress); }
+.section-label-queued      { color: var(--color-queued); }
+.section-label-done        { color: var(--color-done); }
+.section-label-icebox      { color: var(--color-icebox); }
+
+
+/* === Task rows === */
+.task {
+  background: #fff;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius);
+  padding: var(--row-padding-y) 10px;
+  margin-bottom: var(--row-gap);
+}
+
+.task .task-header {
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+}
+
+.task .key {
+  font-family: var(--font-mono);
+  font-size: var(--step-mono);
+  color: var(--color-accent);
+  flex-shrink: 0;
+}
+
+.task .title {
+  font-size: var(--step-0);
+  font-weight: 500;
+  color: var(--color-text);
+}
+
+.task .desc {
+  font-size: var(--step-mono);
+  color: var(--color-text-muted);
+  margin-top: 2px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.task .subtask-indicator {
+  font-size: var(--step-mono);
+  color: var(--color-text-muted);
+  margin-top: 2px;
+}
+
+
+/* === State-specific row tints === */
+.state-in-progress .task { background: var(--tint-in-progress); }
+.state-queued .task      { background: var(--tint-queued); }
+.state-done .task        { background: var(--tint-done); }
+.state-icebox .task      { background: var(--tint-icebox); }
+
+/* Done tasks are slightly desaturated */
+.state-done .task {
+  opacity: 0.75;
+}
+
+
+/* === Empty states === */
+.empty {
+  font-size: var(--step-0);
+  color: var(--color-text-muted);
+  font-style: italic;
+  padding: 20px 0;
+  text-align: center;
+}