From 1d18e2142012bfea53f1a06811805f19ee899461 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Wed, 2 Sep 2026 16:47:47 +0100 Subject: [PATCH] feat(maestro): web sidebar activity rail Replaces the inline activity dot with a coloured bar positioned inside each row's own indent gutter, per the updated docs/SIDEBAR.md contract. Active breathes, awaiting-input blinks, error flashes; idle stays solid. prefers-reduced-motion leaves every state static. --- web/src/catalog.test.ts | 16 ++++++------ web/src/catalog.ts | 21 +++++++++++----- web/src/kit.ts | 15 +++++++++--- web/src/styles.css | 54 ++++++++++++++++++++++++++++++++++++----- 4 files changed, 83 insertions(+), 23 deletions(-) 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 651bab5..7774d68 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -56,8 +56,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", @@ -65,17 +66,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)}` @@ -154,14 +162,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 352afe2..df3592b 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 205ea26..ca3638f 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -926,12 +926,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 { @@ -2117,6 +2139,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) { @@ -2166,7 +2207,8 @@ input.leaf-rename:focus { } .brand-dot, [data-status="running"] .leaf-status::before, - .tally-live::before { + .tally-live::before, + .activity-rail { animation: none; } }