diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs index b21bdde..59a51b7 100644 --- a/crates/maestro-tui/src/palette.rs +++ b/crates/maestro-tui/src/palette.rs @@ -13,8 +13,8 @@ //! //! Each surface carries its own activity colours. A state colour picked for //! the dark card background is not legible on the selection background, so -//! the selected card uses the selection surface's own state colours; the dot -//! keeps carrying the state either way (docs/SIDEBAR.md). +//! the selected card uses the selection surface's own state colours; the +//! rail keeps carrying the state either way (docs/SIDEBAR.md). //! //! [`Palette::DARK`] — graphite, no accent hue — is the default. //! [`Palette::LIGHT`] is its light peer and [`Palette::ANSI`] uses diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 01518a0..a773a99 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -1,7 +1,7 @@ //! Sidebar widget — session cards per `docs/SIDEBAR.md`. //! -//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name. -//! Line 2: activity dot + duration in the state's colour, plus the +//! Line 1: activity rail + status glyph (● attached / ○ live / ✗ dead) + name. +//! Line 2: activity rail + duration in the state's colour, plus the //! adaptive detail, blocked-work, exited, and command fragments. use std::collections::{BTreeMap, HashSet}; @@ -33,6 +33,9 @@ const META_LINE_AFTER_SECS: i64 = 30; const PLAUSIBLE_SINCE_YEAR: i32 = 2010; const INDENT: &str = " "; const META_INDENT: &str = " "; +/// docs/SIDEBAR.md: a one-column activity rail leads the indent gutter on +/// both card rows, replacing the old inline dot glyph on the meta line. +const RAIL: &str = "▌"; /// Cards use the selection surface only while the sidebar owns focus. An /// unfocused highlight remains an edge-marked cursor without claiming the @@ -203,7 +206,9 @@ impl Sidebar { ); let chevron = Span::styled(chevron, base); let count = Span::styled(count, surface.muted_style().bg(row_bg)); - let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width()); + // "1 +" is the blank leading column matching the session rows' rail. + let label_budget = + width.saturating_sub(1 + prefix.width() + chevron.width() + count.width()); let mut label = Vec::new(); if let Some(stem) = name.stem { label.push(Span::styled( @@ -213,7 +218,9 @@ impl Sidebar { } label.push(Span::styled(name.last, base)); - let mut spans = vec![prefix, chevron]; + // A blank leading column, matching the width the activity rail adds + // to session rows, so folder labels keep aligning under names. + let mut spans = vec![Span::styled(" ", base), prefix, chevron]; spans.extend(fit_to_width(label, label_budget)); spans.push(count); pad_to_width(spans, width, base) @@ -227,11 +234,13 @@ impl Sidebar { }; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(highlighted, focused); - let prefix = INDENT.chars().count() + 2; + let prefix = 1 + INDENT.chars().count() + 2; let name_budget = width.saturating_sub(prefix); let edge = highlighted && !focused; + let rail_style = base.fg(activity_color(s.activity, highlighted, focused)); let mut spans: Vec> = vec![ + Span::styled(RAIL, rail_style), Span::styled( if edge { "▏ " } else { INDENT }, if edge { @@ -250,7 +259,8 @@ impl Sidebar { pad_to_width(spans, width, base) } - /// The adaptive second line: ` [] [] [ | exited] `. + /// The adaptive second line: `[] [] [ | exited] `, + /// led by the activity rail (docs/SIDEBAR.md). fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec> { let now = Utc::now(); let held = held_for(s, now); @@ -261,7 +271,7 @@ impl Sidebar { let dead = s.status == SessionStatus::Dead; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); let base = card_base(highlighted, focused); - let dot_style = + let rail_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); let muted = palette::active() .surface(highlighted && focused) @@ -269,6 +279,7 @@ impl Sidebar { .bg(base.bg.unwrap_or(palette::active().chrome_bg)); let edge = highlighted && !focused; let mut spans = vec![ + Span::styled(RAIL, rail_style), Span::styled( if edge { " ▏ " } else { META_INDENT }, if edge { @@ -277,11 +288,9 @@ impl Sidebar { base }, ), - Span::styled("●", dot_style), ]; if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) { - spans.push(Span::styled(" ", base)); - spans.push(Span::styled(format_state_duration(d), dot_style)); + spans.push(Span::styled(format_state_duration(d), rail_style)); } let mut tail: Vec> = Vec::new(); if let Some(b) = blocked { @@ -689,7 +698,7 @@ mod tests { /// The additions and deletions are the only coloured fragments: they are /// what a reader scans for, and colouring the rest would make the line - /// compete with the activity dot. + /// compete with the activity rail. #[test] fn the_diff_colours_only_its_additions_and_deletions() { let mut s = session_named(1, "claude"); @@ -735,8 +744,8 @@ mod tests { assert_eq!(content(&all[0]), "sessions"); assert!(row_containing(&all, "claude").contains('○')); assert!( - all[3].contains('●'), - "line 2 must carry the dot: {:?}", + all[3].contains(RAIL), + "line 2 must carry the rail: {:?}", all[3] ); assert!( @@ -752,7 +761,7 @@ mod tests { } /// Rule 1 of docs/SIDEBAR.md: held in state longer than 30s earns the - /// meta line, with the floored duration next to the dot. + /// meta line, with the floored duration next to the rail. #[test] fn long_held_session_gets_a_meta_line_with_duration() { let mut s = session_named(1, "claude"); @@ -765,7 +774,7 @@ mod tests { let all = rows(&draw(&sb, 28, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let meta = &all[idx + 1]; - assert!(meta.contains('●'), "meta line carries the dot: {meta:?}"); + assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}"); assert!(meta.contains("1m"), "expected 1m in: {meta:?}"); } @@ -782,10 +791,10 @@ mod tests { assert_eq!(fmt(7200), "2h"); } - /// The dot and duration carry the activity state's colour; the state word - /// is never spelled out (docs/SIDEBAR.md colour table). + /// The rail and duration carry the activity state's colour; the state + /// word is never spelled out (docs/SIDEBAR.md colour table). #[test] - fn meta_line_dot_uses_the_contract_colour_per_state() { + fn meta_line_rail_uses_the_contract_colour_per_state() { for state in [ ActivityState::Active, ActivityState::Idle, @@ -808,7 +817,7 @@ mod tests { } } - /// The selected card keeps carrying its state on the dot, but in the + /// The selected card keeps carrying its state on the rail, but in the /// selection surface's own colours. A state colour picked for the dark /// card background painted onto the selection background is the contrast /// bug this palette fixes — this test fails if it comes back. @@ -845,7 +854,7 @@ mod tests { ); assert!( on_selection(p.selection.activity(state)), - "{state:?}: the dot must still carry the state on the selected card" + "{state:?}: the rail must still carry the state on the selected card" ); let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state)) .expect("palette roles are RGB"); @@ -945,7 +954,7 @@ mod tests { let all = rows(&draw(&sb, 40, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); let meta = &all[idx + 1]; - assert!(meta.contains('●'), "meta line carries the dot: {meta:?}"); + assert!(meta.contains(RAIL), "meta line carries the rail: {meta:?}"); assert!( meta.contains("1m"), "duration precedes the detail: {meta:?}" @@ -1018,7 +1027,7 @@ mod tests { /// docs/SIDEBAR.md: a payload predating #102 carries an epoch /// `activity_since` and so contributes no duration — a card must not claim - /// the session has been idle since 1970. The dot still renders when some + /// the session has been idle since 1970. The rail still renders when some /// other trigger puts line 2 on screen. #[test] fn epoch_activity_since_renders_no_duration() { @@ -1035,10 +1044,10 @@ mod tests { rendered.contains("exited"), "dead session must still show line 2:\n{rendered}" ); - for line in rendered.lines().filter(|l| l.contains('●')) { + for line in rendered.lines().filter(|l| l.contains(RAIL)) { assert!( !line - .split('●') + .split(RAIL) .nth(1) .unwrap_or("") .trim_start() @@ -1101,7 +1110,7 @@ mod tests { } #[test] - fn narrow_meta_line_keeps_dot_and_truncates_command() { + fn narrow_meta_line_keeps_rail_and_truncates_command() { let s = session_named(1, "name"); let sb = Sidebar { sessions: vec![s], @@ -1110,8 +1119,8 @@ mod tests { }; let all = rows(&draw(&sb, 9, 6)); assert!( - all[3].contains('●'), - "dot must survive narrow cards: {:?}", + all[3].contains(RAIL), + "rail must survive narrow cards: {:?}", all[3] ); assert!( diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 499147f..b3ee4ad 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -37,23 +37,45 @@ itself is not. Line 2 reads left to right: ``` - [] [] [ | exited] +[] [] [ | exited] ``` -Its minimum content is therefore the dot and the command. +Its minimum content is therefore just the command. -`` is a single filled glyph (`●` on both clients) carrying the activity -state's colour, and `` follows in the same colour when shown. There -is no state word and no second badge: the dot exists so the state is still -legible when a row is dimmed or the terminal renders the duration in the -default fg. The dot is an unconditional part of line 2; the bracketed -fragments appear only when their trigger holds. +The activity state does not render as an inline glyph on line 2 — a **rail** +carries it instead: a coloured bar in the gutter to the left of the card, the +space nesting already reserves for indentation. The rail paints for every +card, `Idle` included, and is independent of whatever line 2 has to say; a +card with nothing else to report still shows its rail next to the bare +command. `` follows in the rail's colour when shown. There is no +state word and no second badge: the rail exists so the state is still legible +when a row is dimmed or the terminal renders the duration in the default fg. + +In the **web client** the rail is a 5px bar positioned inside the row's own +indent gutter — it shifts outward with nesting depth exactly as the gutter +does — spanning the full card height. It carries motion, reserved for states +that need attention (DESIGN.md's "living signals" rule): a slow breathing +opacity while `Active`, a hard blink while `AwaitingInput`, a fast flash while +`Error`. `Idle` is solid and still, on purpose. `prefers-reduced-motion` +leaves every state as a solid bar. + +In the **TUI** the rail is a one-column-wide block glyph (`▌`) in the state's +palette role, leading the existing indent gutter (`INDENT`/`META_INDENT`) on +both the name row and the meta row, so the two lines read as one continuous +bar. Folder headers gain a matching blank leading column so labels still +align under session names. The TUI has no render cadence to animate against, +so its rail is always static — colour is the whole signal there, as it +always was. + +Either client's rail carries the accessibility duty the old inline dot did: +it is the element whose `title`/`aria-label` names the state, so status still +never relies on colour alone. `` renders only once the session has been held in its current activity state longer than **30 seconds** (`activity_since` from #102) — under -that threshold the duration is absent and the dot renders alone. A payload -that predates #102 (no usable `activity_since`) likewise contributes no -duration — the dot still renders. This rule now governs only the duration +that threshold the duration is absent and the rail carries the state alone. A +payload that predates #102 (no usable `activity_since`) likewise contributes +no duration — the rail still renders. This rule now governs only the duration fragment; it no longer decides whether line 2 exists. `` is an agent-reported string (#105) — e.g. why a session is in diff --git a/web/src/catalog.test.ts b/web/src/catalog.test.ts index fd0e4ab..bb9f461 100644 --- a/web/src/catalog.test.ts +++ b/web/src/catalog.test.ts @@ -224,9 +224,10 @@ describe("session cards (#107)", () => { activity_since: ago(5_000), }); const el = renderSidebar(storeWith([s])).el; - const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + const row = rowFor(el, s.id); + const line2 = row?.querySelector(".card-line2"); expect(line2).not.toBeNull(); - expect(line2?.querySelector(".activity-dot")).not.toBeNull(); + expect(row?.querySelector(".activity-rail")).not.toBeNull(); expect(line2?.querySelector(".activity-duration")).toBeNull(); expect(line2?.querySelector(".leaf-cmd")).not.toBeNull(); }); @@ -238,9 +239,10 @@ describe("session cards (#107)", () => { activity_since: ago(4 * 60_000 + 30_000), }); const el = renderSidebar(storeWith([s])).el; - const line2 = rowFor(el, s.id)?.querySelector(".card-line2"); + const row = rowFor(el, s.id); + const line2 = row?.querySelector(".card-line2"); expect(line2?.dataset.activity).toBe("active"); - expect(line2?.querySelector(".activity-dot")).not.toBeNull(); + expect(row?.querySelector(".activity-rail")).not.toBeNull(); expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m"); }); @@ -355,9 +357,9 @@ describe("session cards (#107)", () => { }); const el = renderSidebar(storeWith([s])).el; const row = rowFor(el, s.id) as HTMLElement; - const dot = row.querySelector(".activity-dot"); - expect(dot?.getAttribute("title")).toContain("Awaiting input"); - expect(dot?.getAttribute("aria-label")).toContain("Awaiting input"); + const rail = row.querySelector(".activity-rail"); + expect(rail?.getAttribute("title")).toContain("Awaiting input"); + expect(rail?.getAttribute("aria-label")).toContain("Awaiting input"); expect(row.textContent).not.toMatch(/awaiting.?input/i); }); diff --git a/web/src/catalog.ts b/web/src/catalog.ts index d2314f6..0a0a77c 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -58,8 +58,9 @@ const collapsed = new Set(); * would otherwise replace the field the user is typing into. */ let renamingId: string | null = null; -// docs/SIDEBAR.md: the state word is never rendered — the dot's colour carries -// it. It survives only in tooltips/aria labels so meaning is not colour-only. +// docs/SIDEBAR.md: the state word is never rendered — the rail's colour +// carries it. It survives only in tooltips/aria labels so meaning is not +// colour-only. const STATE_WORDS: Record = { active: "Active", idle: "Idle", @@ -67,17 +68,24 @@ const STATE_WORDS: Record = { error: "Error", }; +/** docs/SIDEBAR.md: the activity rail sits inside the row's own indent + * gutter, so it shifts outward with nesting depth exactly as that gutter + * does. `depth * 16 + 8` is the same expression `renderNode` uses for the + * row's own padding — the rail sits just inside it. */ +function activityRailMarkup(slug: string, stateWord: string, depth: number): string { + const left = depth * 16 + 2; + return ``; +} + function metaLine(s: Session, now: number): string { const dead = s.status === "Dead"; const blockers = sessionBlockers(s); const slug = activityColorSlug(s.activity ?? "Idle"); - const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; const held = activityHeldMs(s.activity_since, now); const duration = held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null; const fragments = [ - ``, duration ? `${duration}` : "", s.activity_detail ? `${escapeHtml(s.activity_detail)}` @@ -156,14 +164,15 @@ function renderNode(node: TreeNode, depth: number): HTMLElement { const running = s.status === "Running"; row.dataset.status = running ? "running" : "dead"; const name = sessionName(s); - const stateWord = - STATE_WORDS[activityColorSlug(s.activity ?? "Idle")] ?? STATE_WORDS.idle; + const slug = activityColorSlug(s.activity ?? "Idle"); + const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle; row.setAttribute( "aria-label", `${name}, ${running ? "running" : "exited"}, ${stateWord}`, ); // docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional. row.innerHTML = ` + ${activityRailMarkup(slug, stateWord, depth)} ${escapeHtml(name)} diff --git a/web/src/kit.ts b/web/src/kit.ts index f1a721e..1883aab 100644 --- a/web/src/kit.ts +++ b/web/src/kit.ts @@ -66,12 +66,15 @@ function leafRow(opts: { (row as HTMLAnchorElement).href = "#/kit"; row.dataset.status = opts.status; if (opts.active) row.classList.add("is-active"); - row.style.paddingLeft = `${(opts.indent ?? 0) * 16 + 8}px`; + const depth = opts.indent ?? 0; + row.style.paddingLeft = `${depth * 16 + 8}px`; const flags = [ opts.status === "dead" ? `exited` : "", opts.cmd !== opts.name ? `${opts.cmd}` : "", ].join(""); + const activity = opts.activity ?? "idle"; row.innerHTML = ` + ${opts.name} @@ -79,7 +82,7 @@ function leafRow(opts: { - ${opts.activity ? `${opts.duration ?? ""}${flags}` : ""} + ${opts.activity ? `${opts.duration ?? ""}${flags}` : ""} `; return row; } @@ -201,11 +204,15 @@ export function renderKit(): HTMLElement { const activeLine = h( "span", "card-line2", - `4m`, + `4m`, ); activeLine.setAttribute("data-activity", "active"); + const rail = h("span", "activity-rail"); + rail.dataset.activity = "active"; + rail.style.cssText = "position:static;display:inline-block;height:14px;"; body.append( specimen("folder-count", h("span", "folder-count", "4")), + specimen("activity-rail · active", rail), specimen("card-line2 · active", activeLine), specimen("attach-state · open", h("span", "attach-state", "live")), ); @@ -225,7 +232,7 @@ export function renderKit(): HTMLElement { { const { el, body } = section( "Session rows — nested by host, then directory", - "docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); line 2 only when there's something to say — dot + duration in the state's colour, then blocked-work / exited fragments.", + "docs/SIDEBAR.md cards: line 1 is name + top-right actions (the name truncates before it can reach them); the activity rail sits in the indent gutter to the left; line 2 carries duration in the state's colour, then blocked-work / exited fragments, whenever there's something to say.", ); const list = h("div", "kit-tree"); list.append( diff --git a/web/src/styles.css b/web/src/styles.css index de547f7..83fa9b8 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1393,12 +1393,34 @@ input.leaf-rename:focus { color: var(--danger); } -.activity-dot { - width: 7px; - height: 7px; - border-radius: 50%; +/* docs/SIDEBAR.md: the activity rail replaces the old inline dot. It sits + inside the row's own indent gutter (`.tree-leaf` is already positioned), + at a `left` set inline per nesting depth — see `activityRailMarkup` in + catalog.ts. Idle stays solid and still on purpose: motion is reserved for + states that need attention (DESIGN.md's "living signals" rule). */ +.activity-rail { + position: absolute; + top: 4px; + bottom: 4px; + width: 5px; + border-radius: 2px; background: currentColor; - flex: 0 0 auto; +} + +.activity-rail[data-activity="active"] { + color: var(--live); + animation: pulse 2.4s var(--ease-out) infinite; +} +.activity-rail[data-activity="idle"] { + color: var(--border-strong); +} +.activity-rail[data-activity="awaiting-input"] { + color: var(--warn); + animation: blink 1.2s steps(1) infinite; +} +.activity-rail[data-activity="error"] { + color: var(--danger); + animation: rail-flash 1.4s ease-out infinite; } .activity-duration { @@ -2593,6 +2615,25 @@ input.leaf-rename:focus { } } +@keyframes rail-flash { + 0%, + 100% { + opacity: 1; + } + 15% { + opacity: 0.25; + } + 30% { + opacity: 1; + } + 45% { + opacity: 0.25; + } + 60% { + opacity: 1; + } +} + /* ---------- responsive ---------- */ @media (max-width: 520px) { @@ -2642,7 +2683,8 @@ input.leaf-rename:focus { } .brand-dot, [data-status="running"] .leaf-status::before, - .tally-live::before { + .tally-live::before, + .activity-rail { animation: none; } }