serve: refine web UI polish and usability
- Expand/collapse: tasks with descriptions or subtasks use <details>
  elements with a rotating chevron indicator
- Tags: display task tags as styled pills next to the title
- Keyboard navigation: j/k and arrow keys to move between tasks,
  Enter/Space to toggle expand on focused task
- Focus styles: visible focus ring for keyboard users
- Responsive layout: 3-col at wide, 2-col at 960px, single-col at 600px
- Equal-width columns, board-level gap instead of per-panel padding
- Remove in-progress/queued section subheadings (tint colors suffice)
change uuktvxvxuuvuvqpyqkwmmtnlwnyrtyks
commit 1ab23d316154f7f000d3cc0a9498cd9df382744d
author Alpha Chen <alpha@kejadlen.dev>
date
parent rzslkyxk
diff --git a/src/bin/ranger/commands/serve.rs b/src/bin/ranger/commands/serve.rs
index 280a7a6..bbc3b1f 100644
--- a/src/bin/ranger/commands/serve.rs
+++ b/src/bin/ranger/commands/serve.rs
@@ -2,7 +2,7 @@ use axum::extract::State;
 use axum::http::header;
 use axum::response::IntoResponse;
 use axum::{Router, routing::get};
-use maud::{DOCTYPE, Markup, html};
+use maud::{DOCTYPE, Markup, PreEscaped, html};
 use ranger::key;
 use ranger::models::Task;
 use ranger::ops;
@@ -63,6 +63,7 @@ struct TaskView {
     key_rest: String,
     title: String,
     description: Option<String>,
+    tags: Vec<String>,
     has_subtasks: bool,
     subtask_count: usize,
     done_subtask_count: usize,
@@ -122,6 +123,7 @@ async fn render_board(state: &AppState) -> color_eyre::Result<Markup> {
                     (render_column_panel("Icebox", "state-icebox", &icebox))
                     (render_column_panel("Done", "state-done", &done))
                 }
+                (keyboard_nav_script())
             }
         }
     })
@@ -139,9 +141,6 @@ fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> Markup
                 div.empty { "No active tasks" }
             } @else {
                 @if !in_progress.is_empty() {
-                    div.section-label.section-label-in-progress {
-                        span.dot { "●" } " In Progress"
-                    }
                     div.state-in-progress {
                         @for task in in_progress {
                             (render_task(task))
@@ -149,9 +148,6 @@ fn render_backlog_panel(in_progress: &[TaskView], queued: &[TaskView]) -> Markup
                     }
                 }
                 @if !queued.is_empty() {
-                    div.section-label.section-label-queued {
-                        span.dot { "●" } " Queued"
-                    }
                     div.state-queued {
                         @for task in queued {
                             (render_task(task))
@@ -185,27 +181,93 @@ fn render_column_panel(label: &str, state_class: &str, tasks: &[TaskView]) -> Ma
 }
 
 fn render_task(task: &TaskView) -> Markup {
+    let has_details = task.description.is_some() || task.has_subtasks;
     html! {
-        div.task {
-            div.task-header {
-                span.key {
-                    span.key-prefix { (task.key_prefix) }
-                    span.key-rest { (task.key_rest) }
+        @if has_details {
+            details.task tabindex="0" {
+                summary.task-header {
+                    span.key {
+                        span.key-prefix { (task.key_prefix) }
+                        span.key-rest { (task.key_rest) }
+                    }
+                    span.title { (task.title) }
+                    @if !task.tags.is_empty() {
+                        span.tags {
+                            @for tag in &task.tags {
+                                span.tag { (tag) }
+                            }
+                        }
+                    }
+                    span.expand-icon { "›" }
+                }
+                div.task-body {
+                    @if let Some(desc) = &task.description {
+                        div.desc { (desc) }
+                    }
+                    @if task.has_subtasks {
+                        div.subtask-indicator {
+                            "◆ " (task.done_subtask_count) "/" (task.subtask_count) " subtasks"
+                        }
+                    }
                 }
-                span.title { (task.title) }
-            }
-            @if let Some(desc) = &task.description {
-                div.desc { (desc) }
             }
-            @if task.has_subtasks {
-                div.subtask-indicator {
-                    "◆ " (task.done_subtask_count) "/" (task.subtask_count) " subtasks"
+        } @else {
+            div.task tabindex="0" {
+                div.task-header {
+                    span.key {
+                        span.key-prefix { (task.key_prefix) }
+                        span.key-rest { (task.key_rest) }
+                    }
+                    span.title { (task.title) }
+                    @if !task.tags.is_empty() {
+                        span.tags {
+                            @for tag in &task.tags {
+                                span.tag { (tag) }
+                            }
+                        }
+                    }
                 }
             }
         }
     }
 }
 
+fn keyboard_nav_script() -> Markup {
+    html! {
+        script {
+            (PreEscaped(r#"
+            (function() {
+                function getTasks() {
+                    return Array.from(document.querySelectorAll('.task'));
+                }
+                function focusTask(tasks, idx) {
+                    if (idx >= 0 && idx < tasks.length) {
+                        tasks[idx].focus();
+                        tasks[idx].scrollIntoView({ block: 'nearest' });
+                    }
+                }
+                document.addEventListener('keydown', function(e) {
+                    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
+                    var tasks = getTasks();
+                    var current = tasks.indexOf(document.activeElement);
+                    if (e.key === 'j' || e.key === 'ArrowDown') {
+                        e.preventDefault();
+                        focusTask(tasks, current < 0 ? 0 : current + 1);
+                    } else if (e.key === 'k' || e.key === 'ArrowUp') {
+                        e.preventDefault();
+                        focusTask(tasks, current < 0 ? 0 : current - 1);
+                    } else if ((e.key === 'Enter' || e.key === ' ') && document.activeElement.tagName === 'DETAILS') {
+                        e.preventDefault();
+                        var details = document.activeElement;
+                        details.open = !details.open;
+                    }
+                });
+            })();
+            "#))
+        }
+    }
+}
+
 async fn to_task_views(
     tasks: &[Task],
     prefixes: &std::collections::HashMap<String, usize>,
@@ -218,6 +280,13 @@ async fn to_task_views(
         let key_prefix = task.key[..prefix_len.min(display_len)].to_string();
         let key_rest = task.key[prefix_len.min(display_len)..display_len].to_string();
 
+        // Fetch tags
+        let tags = ops::tag::list_for_task(&mut *conn, task.id)
+            .await?
+            .into_iter()
+            .map(|t| t.name)
+            .collect();
+
         // Check for subtasks
         let subtasks: Vec<Task> = sqlx::query_as(
             "SELECT id, key, backlog_id, parent_id, title, description, state, position, archived, created_at, updated_at \
@@ -239,6 +308,7 @@ async fn to_task_views(
             key_rest,
             title: task.title.clone(),
             description: task.description.clone(),
+            tags,
             has_subtasks,
             subtask_count,
             done_subtask_count,
diff --git a/static/style.css b/static/style.css
index 24d46a7..3eb5b2b 100644
--- a/static/style.css
+++ b/static/style.css
@@ -46,9 +46,12 @@
 
   /* ── Spacing ── */
   --row-padding-y:  7px;
-  --row-gap:        1px;
+  --row-gap:        3px;
   --panel-padding:  20px;
-  --radius:         2px;
+  --radius:         4px;
+
+  /* ── Focus ── */
+  --color-focus: rgba(61, 96, 152, 0.5);
 }
 
 
@@ -99,21 +102,16 @@ header .counts {
 /* === Board layout === */
 .board {
   display: grid;
-  grid-template-columns: 1fr 280px 300px;
+  grid-template-columns: repeat(3, 1fr);
+  gap: var(--panel-padding);
   height: calc(100vh - 41px);          /* header height */
+  padding: var(--panel-padding);
 }
 
 
 /* === 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 {
@@ -172,9 +170,36 @@ header .counts {
   border-radius: var(--radius);
   padding: var(--row-padding-y) 10px;
   margin-bottom: var(--row-gap);
+  outline: none;
+  transition: box-shadow 0.1s ease;
 }
 
-.task .task-header {
+.task:focus {
+  box-shadow: 0 0 0 2px var(--color-focus);
+  z-index: 1;
+  position: relative;
+}
+
+/* details element reset */
+details.task {
+  cursor: pointer;
+}
+
+details.task > summary {
+  list-style: none;
+}
+
+details.task > summary::-webkit-details-marker {
+  display: none;
+}
+
+details.task > summary::marker {
+  display: none;
+  content: "";
+}
+
+.task .task-header,
+.task > summary.task-header {
   display: flex;
   align-items: baseline;
   gap: 8px;
@@ -200,21 +225,62 @@ header .counts {
   font-size: var(--step-0);
   font-weight: 500;
   color: var(--color-text);
+  flex: 1;
+  min-width: 0;
 }
 
-.task .desc {
+/* === Expand icon === */
+.task .expand-icon {
   font-size: var(--step-mono);
   color: var(--color-text-muted);
-  margin-top: 2px;
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
+  margin-left: auto;
+  flex-shrink: 0;
+  transition: transform 0.15s ease;
+  display: inline-block;
+}
+
+details.task[open] > summary .expand-icon {
+  transform: rotate(90deg);
+}
+
+/* === Task body (expanded) === */
+.task-body {
+  padding: 6px 0 2px 0;
+  margin-top: 4px;
+  border-top: 1px solid var(--color-border);
+}
+
+.task .desc {
+  font-size: var(--step-0);
+  color: var(--color-text-muted);
+  line-height: 1.6;
+  white-space: pre-wrap;
+  word-break: break-word;
 }
 
 .task .subtask-indicator {
   font-size: var(--step-mono);
   color: var(--color-text-muted);
-  margin-top: 2px;
+  margin-top: 4px;
+}
+
+/* === Tags === */
+.task .tags {
+  display: inline-flex;
+  gap: 4px;
+  margin-left: auto;
+  flex-shrink: 0;
+}
+
+.task .tag {
+  font-size: 0.625rem;
+  font-family: var(--font-mono);
+  color: var(--color-accent);
+  background: rgba(61, 96, 152, 0.1);
+  padding: 0 5px;
+  border-radius: 3px;
+  line-height: 1.6;
+  white-space: nowrap;
 }
 
 
@@ -243,3 +309,30 @@ header .counts {
   padding: 20px 0;
   text-align: center;
 }
+
+
+/* === Responsive layout === */
+@media (max-width: 960px) {
+  .board {
+    grid-template-columns: 1fr 1fr;
+    height: auto;
+  }
+
+  /* Backlog panel spans full width */
+  .panel:first-child {
+    grid-column: 1 / -1;
+  }
+}
+
+@media (max-width: 600px) {
+  .board {
+    grid-template-columns: 1fr;
+    height: auto;
+    padding: 16px;
+    gap: 16px;
+  }
+
+  header {
+    padding: 8px 16px;
+  }
+}