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
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;
+}