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