Compare commits

...
5 Commits
Author SHA1 Message Date
Miguel PalhasandClaude Sonnet 5 c8ab4a531e fix(maestro): tool icon left of diff stat on sidebar meta line
ci / flake (push) Successful in 9m17s
ci / web (push) Successful in 1m9s
ci / rust (push) Successful in 3m55s
Meta line fragment order was command last, after the blocked-work diff
stat, so the tool icon sat right of the diff count instead of left.
Reorder to duration/detail/command/diff-stat in both web and TUI
sidebars per docs/SIDEBAR.md, and update the contract doc + TUI tests
to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:17:44 +01:00
naps62 21fc20b30a Merge pull request 'docs(maestro): replace the sidebar's activity dot with a rail' (#130) from indicators into main
ci / web (push) Successful in 3m40s
ci / rust (push) Successful in 5m49s
ci / flake (push) Successful in 6m12s
Reviewed-on: #130
2026-09-02 17:00:58 +01:00
Miguel Palhas abbd15a8b2 feat(maestro): TUI sidebar activity rail
ci / web (pull_request) Successful in 1m20s
ci / rust (pull_request) Successful in 5m47s
ci / flake (pull_request) Successful in 9m14s
Leads the existing indent gutter on both the name row and the meta
row with a one-column block glyph in the state's palette role,
replacing the meta line's inline dot. Folder headers gain a matching
blank column so labels stay aligned under session names.
2026-09-02 16:47:51 +01:00
Miguel Palhas 1d18e21420 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.
2026-09-02 16:47:47 +01:00
Miguel Palhas 38a65e585f docs(maestro): replace the sidebar's activity dot with a rail
The inline dot competed with the meta line's other fragments and left
the indent gutter unused. A rail moves the state signal into that
gutter instead, on both clients.
2026-09-02 16:47:42 +01:00
7 changed files with 168 additions and 70 deletions
+2 -2
View File
@@ -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
+48 -32
View File
@@ -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<Span<'static>> = 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: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
/// The adaptive second line: `[<duration>] [<detail>] <command> [<blocked work> | exited]`,
/// led by the activity rail (docs/SIDEBAR.md).
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
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<Span<'static>> = Vec::new();
if let Some(b) = blocked {
@@ -320,8 +329,8 @@ impl Sidebar {
budget,
));
}
spans.extend(tail);
spans.extend(fit_to_width(vec![command], command_w));
spans.extend(tail);
spans
}
}
@@ -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");
@@ -918,9 +927,11 @@ mod tests {
let all = rows(&draw(&sb, 28, 8));
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
let meta = content(&all[idx + 1]);
let ellipsis = meta.find('…').expect("truncated command must contain …");
let tail = meta.find("~120").expect("diff stat must survive: {meta:?}");
assert!(
meta.trim_end().ends_with('…'),
"truncated command must end with …: {meta:?}"
ellipsis < tail,
"command must truncate before the diff stat: {meta:?}"
);
assert!(
content(&all[idx + 2]).trim().is_empty(),
@@ -945,7 +956,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 +1029,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 +1046,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 +1112,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 +1121,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!(
@@ -1122,7 +1133,7 @@ mod tests {
}
#[test]
fn blocked_warning_survives_before_a_truncated_command() {
fn blocked_warning_survives_after_a_truncated_command() {
let mut s = session_named(1, "name");
s.command = "a-program-name-that-cannot-fit".into();
s.blocked_work = Some(InTheWay {
@@ -1137,7 +1148,12 @@ mod tests {
let all = rows(&draw(&sb, 29, 6));
assert!(all[3].contains("~1"), "{:?}", all[3]);
let meta = content(&all[3]);
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
let ellipsis = meta.find('…').expect("command must truncate");
let tail = meta.find("~1").unwrap();
assert!(
ellipsis < tail,
"diff stat must follow the truncated command: {meta:?}"
);
}
#[test]
+34 -12
View File
@@ -37,23 +37,45 @@ itself is not.
Line 2 reads left to right:
```
<dot> [<duration>] [<detail>] [<diff stat> | exited] <command>
[<duration>] [<detail>] <command> [<diff stat> | exited]
```
Its minimum content is therefore the dot and the command.
Its minimum content is therefore just the command.
`<dot>` is a single filled glyph (`●` on both clients) carrying the activity
state's colour, and `<duration>` 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. `<duration>` 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.
`<duration>` 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.
`<detail>` is an agent-reported string (#105) — e.g. why a session is in
@@ -61,7 +83,7 @@ fragment; it no longer decides whether line 2 exists.
while an agent-reported state is in force (see Precedence): when the report
lapses at its TTL or is replaced, the detail goes with it. The detail
truncates with `…` ahead of the trailing fragments — a long detail never
pushes `<blocked work>`, `exited`, or `<command>` off the line, and neither
pushes `<command>`, `<blocked work>`, or `exited` off the line, and neither
client ever lets it wrap.
`<diff stat>` is the blocked-work breakdown (#83) in diff notation, replacing
+9 -7
View File
@@ -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<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".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<HTMLElement>(".card-line2");
const row = rowFor(el, s.id);
const line2 = row?.querySelector<HTMLElement>(".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<HTMLElement>(".activity-dot");
expect(dot?.getAttribute("title")).toContain("Awaiting input");
expect(dot?.getAttribute("aria-label")).toContain("Awaiting input");
const rail = row.querySelector<HTMLElement>(".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);
});
+16 -7
View File
@@ -58,8 +58,9 @@ const collapsed = new Set<string>();
* 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<string, string> = {
active: "Active",
idle: "Idle",
@@ -67,24 +68,31 @@ const STATE_WORDS: Record<string, string> = {
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 `<span class="activity-rail" data-activity="${slug}" style="left:${left}px" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`;
}
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 = [
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
duration ? `<span class="activity-duration">${duration}</span>` : "",
s.activity_detail
? `<span class="activity-detail">${escapeHtml(s.activity_detail)}</span>`
: "",
commandFragment(s.command),
blockers ? blockedFragment(blockers) : "",
dead ? `<span class="card-flag">exited</span>` : "",
commandFragment(s.command),
];
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
}
@@ -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)}
<span class="card-line1">
<span class="leaf-name">${escapeHtml(name)}</span>
<span class="leaf-actions">
+11 -4
View File
@@ -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" ? `<span class="card-flag">exited</span>` : "",
opts.cmd !== opts.name ? `<span class="card-flag leaf-cmd">${opts.cmd}</span>` : "",
].join("");
const activity = opts.activity ?? "idle";
row.innerHTML = `
<span class="activity-rail" data-activity="${activity}" style="left:${depth * 16 + 2}px" title="${activity}"></span>
<span class="card-line1">
<span class="leaf-name">${opts.name}</span>
<span class="leaf-actions">
@@ -79,7 +82,7 @@ function leafRow(opts: {
<button class="leaf-act leaf-kill" type="button" title="Kill session">✕</button>
</span>
</span>
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-dot" title="${opts.activity}"></span><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
${opts.activity ? `<span class="card-line2" data-activity="${opts.activity}"><span class="activity-duration">${opts.duration ?? ""}</span>${flags}</span>` : ""}
`;
return row;
}
@@ -201,11 +204,15 @@ export function renderKit(): HTMLElement {
const activeLine = h(
"span",
"card-line2",
`<span class="activity-dot"></span><span class="activity-duration">4m</span>`,
`<span class="activity-duration">4m</span>`,
);
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(
+48 -6
View File
@@ -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;
}
}