diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index 0870265..d3e6d2e 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -1179,6 +1179,10 @@ impl App { sessions: snap.sessions.clone(), highlight: self.state.sidebar_highlight.clone(), attached, + // The active tab's session, not the displayed one: a preview is + // shown in the pane but never becomes what the operator picked, + // so it must not move this fill (`docs/PREVIEW.md`). + pane_session: self.state.active_session(), }; let shown_sid = self.state.displayed_session(); let previewing = self.state.preview.is_some(); @@ -1269,6 +1273,7 @@ fn visible_sidebar_order( sessions: snap.sessions.clone(), highlight: None, attached: HashSet::new(), + pane_session: None, } .rendered_items_with_collapsed(collapsed) } diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs index 59a51b7..8ecae24 100644 --- a/crates/maestro-tui/src/palette.rs +++ b/crates/maestro-tui/src/palette.rs @@ -813,6 +813,21 @@ impl Palette { } } + /// Background for the card whose session the pane is showing + /// (`docs/SIDEBAR.md`). It sits partway from the card surface to the + /// selection surface, so the sidebar reads as one ladder: unfilled, half + /// filled for the session in the pane, fully filled under the cursor. + /// + /// Blended rather than added as a third `Surface` — a new surface means + /// eight more colours hand-picked seventeen times — which is also why it + /// borrows the *selection* surface's foregrounds: those are the ones + /// already picked for a filled row. A preset whose colours the terminal + /// owns (`ansi`) has nothing to blend, so it keeps the card background and + /// says "in the pane" with the filled glyph alone. + pub fn pane_row_bg(&self) -> Color { + mix(self.card.bg, self.selection.bg, 0.55) + } + /// Border style for a panel, by whether it holds focus. pub fn border_style(&self, focused: bool) -> Style { if focused { @@ -873,6 +888,16 @@ pub fn active() -> Palette { *active_slot().read().expect("active palette lock poisoned") } +/// Blend `amount` of `to` into `from`, in plain sRGB. Both must be RGB, or +/// there is nothing to blend and `from` comes back unchanged. +fn mix(from: Color, to: Color, amount: f64) -> Color { + let (Color::Rgb(fr, fg, fb), Color::Rgb(tr, tg, tb)) = (from, to) else { + return from; + }; + let lerp = |a: u8, b: u8| (a as f64 + (b as f64 - a as f64) * amount).round() as u8; + Color::Rgb(lerp(fr, tr), lerp(fg, tg), lerp(fb, tb)) +} + /// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed /// luminance — they are whatever the terminal theme says — so they return /// `None` and cannot be contrast-checked. @@ -937,6 +962,43 @@ mod tests { } } + /// The pane row is the middle rung: a visible fill against the card, still + /// a different surface from the cursor's, and carrying the selection + /// surface's foregrounds, which it borrows rather than shipping its own. + #[test] + fn the_pane_row_is_a_rung_between_the_card_and_the_selection() { + for (preset, p) in rgb_presets() { + let bg = p.pane_row_bg(); + let from_card = contrast_ratio(p.card.bg, bg) + .unwrap_or_else(|| panic!("{preset}.pane_row_bg must be RGB")); + assert!( + from_card >= 1.15, + "{preset}: pane row {from_card:.2} from the card background is not a visible fill" + ); + let from_selection = + contrast_ratio(p.selection.bg, bg).expect("selection.bg must be RGB"); + assert!( + from_selection >= 1.15, + "{preset}: pane row {from_selection:.2} from the cursor's fill — the highlight would vanish on the session already in the pane" + ); + for (role, color) in p.selection.roles() { + let ratio = contrast_ratio(bg, color).expect("selection role must be RGB"); + let floor = if role == "dim" { 3.0 } else { 4.5 }; + assert!( + ratio >= floor, + "{preset}.selection.{role} on the pane row is {ratio:.2}, below {floor}" + ); + } + } + } + + /// A preset the terminal themes has nothing to blend from. + #[test] + fn ansi_keeps_the_card_background_for_the_pane_row() { + let p = Palette::ANSI; + assert_eq!(p.pane_row_bg(), p.card.bg); + } + #[test] fn every_preset_supplies_every_role() { for (name, p) in Palette::presets() { diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index db547f9..5ecf71b 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -415,6 +415,7 @@ mod tests { sessions: Vec::new(), highlight: None, attached: HashSet::new(), + pane_session: None, }; let help = HelpOverlay { lines: Vec::new() }; let state = AppState::new(); @@ -511,6 +512,7 @@ mod tests { sessions: Vec::new(), highlight: None, attached: HashSet::new(), + pane_session: None, }; let help = HelpOverlay { lines: Vec::new() }; let inputs = RenderInputs { @@ -561,6 +563,7 @@ mod tests { sessions: Vec::new(), highlight: None, attached: HashSet::new(), + pane_session: None, }; let help = HelpOverlay { lines: Vec::new() }; let draw_map = |focus| { @@ -601,6 +604,7 @@ mod tests { sessions: Vec::new(), highlight: None, attached: HashSet::new(), + pane_session: None, }; let help = HelpOverlay { lines: Vec::new() }; let render = |focus| { diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 6689ae2..97d9c5e 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -37,14 +37,39 @@ const META_INDENT: &str = " "; /// 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 +/// Which fill a session card wears (docs/SIDEBAR.md). The cursor's fill wins +/// on its own row: a highlight that lands on the session already in the pane +/// still has to be visible. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CardFill { + /// An ordinary card, on the card surface. + None, + /// The session the pane is showing. + Pane, + /// Under the cursor, with the sidebar holding the keyboard. + Cursor, +} + +impl CardFill { + /// Both filled tiers carry the selection surface's foregrounds — those are + /// the colours picked for a filled row, and a colour chosen for the card + /// background is not legible on one. + fn filled(self) -> bool { + self != CardFill::None + } +} + +/// Cards take the cursor's fill only while the sidebar owns focus. An /// unfocused highlight remains an edge-marked cursor without claiming the /// keyboard via a filled selection background. -fn card_base(highlighted: bool, focused: bool) -> Style { +fn card_base(fill: CardFill, focused: bool) -> Style { let palette = palette::active(); - let mut style = palette.surface(highlighted && focused).text_style(); - if focused && !highlighted { - style = style.bg(palette.sidebar_focus_bg); + let mut style = palette.surface(fill.filled()).text_style(); + match fill { + CardFill::Cursor => {} + CardFill::Pane => style = style.bg(palette.pane_row_bg()), + CardFill::None if focused => style = style.bg(palette.sidebar_focus_bg), + CardFill::None => {} } style } @@ -55,6 +80,9 @@ pub struct Sidebar { pub sessions: Vec, pub highlight: Option, pub attached: HashSet, + /// The session the active tab's pane is showing. It wears a fill of its + /// own, so "in the pane" is not carried by the ● glyph alone. + pub pane_session: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -113,8 +141,7 @@ impl Sidebar { lines.push(Line::from(self.group_row(&group, width, focused, home))); if !group.collapsed { for s in &group.sessions { - let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); - let base = card_base(highlighted, focused); + let base = card_base(self.card_fill(s, focused), focused); lines.push(Line::from(self.name_row(s, width, focused))); let meta = self.meta_row(s, width, focused); lines.push(Line::from(pad_to_width(meta, width, base))); @@ -226,6 +253,18 @@ impl Sidebar { pad_to_width(spans, width, base) } + /// Which fill this session's card wears. The cursor wins on its own row, + /// so a highlight parked on the session already in the pane stays visible. + fn card_fill(&self, s: &Session, focused: bool) -> CardFill { + if focused && self.highlight == Some(SidebarItem::Session(s.id)) { + CardFill::Cursor + } else if self.pane_session == Some(s.id) { + CardFill::Pane + } else { + CardFill::None + } + } + fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec> { let glyph = match (s.status, self.attached.contains(&s.id)) { (SessionStatus::Dead, _) => "✗", @@ -233,12 +272,13 @@ impl Sidebar { (SessionStatus::Running, false) => "○", }; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); - let base = card_base(highlighted, focused); + let fill = self.card_fill(s, focused); + let base = card_base(fill, focused); 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 rail_style = base.fg(activity_color(s.activity, fill)); let mut spans: Vec> = vec![ Span::styled(RAIL, rail_style), Span::styled( @@ -257,7 +297,7 @@ impl Sidebar { let name_style = if maestro_protocol::display::is_provisional_name(s) { base.patch( palette::active() - .surface(highlighted && focused) + .surface(fill.filled()) .muted_style() .bg(base.bg.unwrap_or(palette::active().chrome_bg)), ) @@ -283,11 +323,11 @@ impl Sidebar { let blocked = s.blocked_work.filter(|b| !b.is_clear()); let dead = s.status == SessionStatus::Dead; let highlighted = self.highlight == Some(SidebarItem::Session(s.id)); - let base = card_base(highlighted, focused); - let rail_style = - base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused))); + let fill = self.card_fill(s, focused); + let base = card_base(fill, focused); + let rail_style = base.patch(Style::default().fg(activity_color(s.activity, fill))); let muted = palette::active() - .surface(highlighted && focused) + .surface(fill.filled()) .muted_style() .bg(base.bg.unwrap_or(palette::active().chrome_bg)); let edge = highlighted && !focused; @@ -308,7 +348,7 @@ impl Sidebar { let mut tail: Vec> = Vec::new(); if let Some(b) = blocked { let palette = palette::active(); - let surface = palette.surface(highlighted && focused); + let surface = palette.surface(fill.filled()); for (kind, text) in b.stats() { let style = match kind { StatKind::Added => muted.fg(surface.active), @@ -352,10 +392,8 @@ impl Sidebar { /// state word is never spelled out — this colour carries it. The highlighted /// card takes its state colours from the selection surface: a colour picked /// for the dark card background is not legible on the selection background. -fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color { - palette::active() - .surface(highlighted && focused) - .activity(state) +fn activity_color(state: ActivityState, fill: CardFill) -> Color { + palette::active().surface(fill.filled()).activity(state) } /// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours, @@ -520,6 +558,7 @@ mod tests { sessions: vec![session_named(1, "claude"), session_named(2, "bash")], highlight: None, attached: Default::default(), + pane_session: None, }; let rendered = render_to_string(&draw(&sb, 28, 12)); assert!( @@ -544,6 +583,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }) .groups(&HashSet::new()); assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro")); @@ -555,6 +595,7 @@ mod tests { sessions: Vec::new(), highlight: None, attached: Default::default(), + pane_session: None, }; assert!(sidebar.groups(&HashSet::new()).is_empty()); assert!(sidebar.rendered_items().is_empty()); @@ -566,6 +607,7 @@ mod tests { sessions: vec![session_named(1, "claude"), session_named(2, "bash")], highlight: None, attached: Default::default(), + pane_session: None, }; let key = PathBuf::from("/tmp"); let mut collapsed = HashSet::new(); @@ -590,6 +632,7 @@ mod tests { sessions: vec![session], highlight: None, attached: Default::default(), + pane_session: None, }; let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name"); let collapsed = HashSet::from([key]); @@ -620,6 +663,7 @@ mod tests { sessions: vec![session_named(1, "claude")], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sidebar, 40, 8)); assert!(all[1].contains("/tmp")); @@ -635,6 +679,7 @@ mod tests { sessions: vec![session_named(1, "claude")], highlight: None, attached: [sid].into_iter().collect(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 6)); let line = row_containing(&all, "claude"); @@ -645,6 +690,87 @@ mod tests { ); } + /// Whether any cell in the buffer is painted on `bg`. + fn any_cell_on(buf: &Buffer, bg: Color) -> bool { + (0..buf.area.height) + .any(|y| (0..buf.area.width).any(|x| buf[(x, y)].style().bg == Some(bg))) + } + + /// docs/SIDEBAR.md: the session the pane is showing wears a fill, so it is + /// not the ● glyph alone carrying "this is the one on screen". + #[test] + fn the_session_in_the_pane_wears_the_pane_fill() { + let sid = Uuid::from_u128(1); + let p = palette::active(); + let sb = Sidebar { + sessions: vec![session_named(1, "claude")], + highlight: None, + attached: [sid].into_iter().collect(), + pane_session: Some(sid), + }; + assert!(any_cell_on(&draw(&sb, 28, 6), p.pane_row_bg())); + + let none = Sidebar { + pane_session: None, + ..sb + }; + assert!(!any_cell_on(&draw(&none, 28, 6), p.pane_row_bg())); + } + + /// The cursor's fill wins on its own row: a highlight that lands on the + /// session already in the pane has to stay visible. + #[test] + fn the_cursor_outranks_the_pane_fill_on_its_own_row() { + let sid = Uuid::from_u128(1); + let p = palette::active(); + let sb = Sidebar { + sessions: vec![session_named(1, "claude")], + highlight: Some(SidebarItem::Session(sid)), + attached: [sid].into_iter().collect(), + pane_session: Some(sid), + }; + let buf = draw_with_focus(&sb, 28, 6, true); + assert!(any_cell_on(&buf, p.selection.bg), "cursor fill is missing"); + assert!( + !any_cell_on(&buf, p.pane_row_bg()), + "the pane fill is showing through the cursor's own row" + ); + } + + /// A card the pane is showing is a filled row, so it takes the selection + /// surface's foregrounds — a colour picked for the card background is not + /// legible on a fill. + #[test] + fn the_pane_row_carries_selection_foregrounds() { + let sid = Uuid::from_u128(1); + let p = palette::active(); + let mut sess = session_named(1, "claude"); + sess.activity = ActivityState::AwaitingInput; + let sb = Sidebar { + sessions: vec![sess], + highlight: None, + attached: [sid].into_iter().collect(), + pane_session: Some(sid), + }; + let buf = draw(&sb, 28, 6); + let on_pane_row = |fg: Color| { + (0..buf.area.height).any(|y| { + (0..buf.area.width).any(|x| { + let st = buf[(x, y)].style(); + st.bg == Some(p.pane_row_bg()) && st.fg == Some(fg) + }) + }) + }; + assert!( + on_pane_row(p.selection.activity(ActivityState::AwaitingInput)), + "the rail must carry the state in the filled surface's colours" + ); + assert!( + !on_pane_row(p.card.activity(ActivityState::AwaitingInput)), + "a card surface colour is painted onto the pane fill" + ); + } + #[test] fn dead_session_uses_cross_glyph() { let mut sess = session_named(1, "claude"); @@ -653,6 +779,7 @@ mod tests { sessions: vec![sess], highlight: None, attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 6); let all = rows(&buf); @@ -681,6 +808,7 @@ mod tests { sessions: vec![blocked, clear, unprobed], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 44, 10)); @@ -727,6 +855,7 @@ mod tests { sessions: vec![generated, settled], highlight: None, attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 44, 8); let palette = palette::active(); @@ -759,6 +888,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 44, 8); let palette = palette::active(); @@ -785,6 +915,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); assert_eq!(content(&all[0]), "sessions"); @@ -816,6 +947,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -855,6 +987,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 8); let found = (0..buf.area.height) @@ -883,6 +1016,7 @@ mod tests { sessions: vec![s], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 8); let on_selection = |fg: Color| { @@ -922,6 +1056,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -941,6 +1076,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 60, 6)); let meta = content(&all[3]); @@ -969,6 +1105,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -998,6 +1135,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 40, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -1025,6 +1163,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 40, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -1053,6 +1192,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 42, 8)); let idx = all.iter().position(|l| l.contains("claude")).unwrap(); @@ -1086,6 +1226,7 @@ mod tests { sessions: vec![legacy], highlight: None, attached: Default::default(), + pane_session: None, }; let rendered = render_to_string(&draw(&sb, 40, 8)); assert!( @@ -1128,6 +1269,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let rendered = render_to_string(&draw(&sb, 28, 6)); assert!( @@ -1143,6 +1285,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 40, 6)); assert!( @@ -1164,6 +1307,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 9, 6)); assert!( @@ -1190,6 +1334,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 29, 6)); assert!(all[3].contains("~1"), "{:?}", all[3]); @@ -1214,6 +1359,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 40, 6)); assert!(all[3].contains("~1"), "{:?}", all[3]); @@ -1235,6 +1381,7 @@ mod tests { sessions: vec![highlighted], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 8); for y in 0..buf.area.height { @@ -1260,6 +1407,7 @@ mod tests { sessions: vec![one, two], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 8); let all = rows(&buf); @@ -1295,6 +1443,7 @@ mod tests { sessions: vec![session_named(1, "claude")], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let buf = draw_with_focus(&sb, 28, 8, false); let y = (0..buf.area.height) @@ -1314,6 +1463,7 @@ mod tests { sessions: vec![session_named(1, "claude")], highlight: None, attached: Default::default(), + pane_session: None, }, 28, 8, @@ -1334,6 +1484,7 @@ mod tests { sessions: vec![s], highlight: None, attached: Default::default(), + pane_session: None, }; let buf = draw(&sb, 28, 8); let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width; @@ -1352,6 +1503,7 @@ mod tests { sessions: vec![session_named(1, "claude")], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let line = content(row_containing(&all, "claude")); @@ -1369,6 +1521,7 @@ mod tests { sessions: vec![one, two], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let line = row_containing(&all, "bash"); @@ -1388,6 +1541,7 @@ mod tests { sessions: vec![s], highlight: Some(SidebarItem::Session(Uuid::from_u128(1))), attached: Default::default(), + pane_session: None, }; let all = rows(&draw(&sb, 28, 8)); let line = row_containing(&all, "an-unreasonably"); diff --git a/docs/PREVIEW.md b/docs/PREVIEW.md index 79e2de7..e22ac23 100644 --- a/docs/PREVIEW.md +++ b/docs/PREVIEW.md @@ -49,9 +49,9 @@ Web client (`--preview` and friends in `web/src/styles.css`): | The sidebar row being previewed | Filled with the preview hue, over whatever open/focused state the row already had. | | A hovered sidebar row, and the keyboard cursor on a session | A lighter wash of the same hue, because both are what previews a row. | -Rows say it with fill, not with an edge: the only ring left in the session list -is `.is-active`'s, which means "open in a pane". The dashed edges belong to the -pane — the canvas ring and the throwaway tab. +Rows say it with fill, not with an edge — see the tier ladder in +`docs/SIDEBAR.md`, which previewing sits at the bottom of. The dashed edges +belong to the pane: the canvas ring and the throwaway tab. The hue is a palette token, not a fixed colour: a palette whose accent already sits on it moves it (`--preview-h`), so "previewing" and "picked" never collide. diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index da8d737..7c04d9c 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -177,6 +177,38 @@ surface; a colour chosen for the card background is never painted onto the selection background. Contrast is a property of the palette (values are RGB) and is asserted in the palette's tests, not left to the terminal theme. +## Which row is filled + +Both clients say every one of these with a **background fill**, never an edge. +A row is usually several of them at once, so they form a ladder: the tier +furthest down wins the paint. + +| Tier | Web | TUI | +|---|---|---| +| Alive | 6% accent wash | — (the rail carries it) | +| Under the pointer or the keyboard walk | preview hue, light | selection surface, while the sidebar holds the keyboard | +| Open in some pane, not the focused one | 6% accent wash (`.is-open`) | — (the `●` glyph carries it) | +| Showing in the pane | `--accent-fill` | `pane_row_bg()` — partway from the card surface to the selection surface | +| Previewing (`docs/PREVIEW.md`) | `--preview-tint` | — (no preview colour role yet) | + +The web ladder is written as one ordered run of equal-specificity rules in +`styles.css`, every qualifier inside `:where()`, so the order they appear in +*is* the precedence. Add a tier where it belongs in that run, never by piling +on another class to out-weigh the rule above it. + +The TUI's two filled tiers both carry the **selection** surface's foregrounds, +since those are the colours picked for a filled row. `pane_row_bg()` is +blended from the two surfaces rather than shipped as a third one — a third +surface would mean eight more colours hand-picked seventeen times — and the +palette tests assert it stays a visible step from both. The cursor's fill wins +on its own row, so a highlight parked on the session already in the pane is +still visible. + +The one edge left in either list is the TUI's `▏` marker, which stands in for +the cursor's fill while the pane holds the keyboard, and the web's ring on a +highlighted **folder** row — a folder previews nothing, so it has no fill to +step up to. + The duration text inherits its state's colour; the exited and command fragments use the surface's `muted` role, as do the diff stat's `~`, `?` and `↑` fragments. Only `+` and `-` step outside the surface's muted role, and they diff --git a/web/src/styles.css b/web/src/styles.css index c4e3242..45d5ad4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -101,9 +101,11 @@ oklch(0.99 0.005 var(--preview-h)), oklch(0.19 0.045 var(--preview-h)) ); - /* Opaque, not a wash: the previewed row reads as a fill, and a fill has to - land on the same colour whatever tier the row was already painted. */ + /* Opaque, not a wash: session rows read as fills, and a fill has to land on + the same colour whatever tier the row was already painted. `--accent-fill` + is the same recipe on the accent, so the two tiers differ by hue alone. */ --preview-tint: color-mix(in oklab, var(--preview) 30%, var(--surface-2)); + --accent-fill: color-mix(in oklab, var(--accent) 30%, var(--surface-2)); --preview-ring: light-dark( oklch(0.55 0.16 var(--preview-h) / 0.5), oklch(0.78 0.14 var(--preview-h) / 0.6) @@ -1148,38 +1150,35 @@ textarea { transition: box-shadow var(--dur-fast) var(--ease-out); } -/* "Alive" reads as a faint wash on the row itself — no per-row status icon. */ -.tree-leaf[data-status="running"] { +/* Session-row tiers (docs/SIDEBAR.md). A row is usually several of these at + once, so every qualifier below sits in `:where()`: each rule is worth the + same specificity and the ladder is decided purely by the order they appear + in, latest wins. Add a tier where it belongs in that order, not by piling on + another class to out-weigh the rule above it. Each tier paints a background + and nothing else — the list says all of this with fill, never an edge. */ + +/* 1. Alive. A faint wash on the row itself — no per-row status icon. */ +.tree-leaf:where([data-status="running"]) { background: color-mix(in oklab, var(--accent) 6%, transparent); } -/* Hovering a row is what previews it (docs/PREVIEW.md), so hover reads in the - preview hue — never the accent, which belongs to the row actually open. */ -.tree-leaf:hover { +/* 2. Under the pointer, or under the keyboard walk. Both are what previews a + row (docs/PREVIEW.md), so both read in the preview hue — never the accent, + which belongs to the row actually open. */ +.tree-leaf:where(:hover, .is-highlighted) { background: color-mix(in oklab, var(--preview) 12%, var(--surface-2)); } -.tree-leaf[data-status="running"]:hover { +.tree-leaf:where([data-status="running"]):where(:hover, .is-highlighted) { background: color-mix(in oklab, var(--preview) 18%, var(--surface-2)); } -.tree-leaf:focus-visible { +.tree-leaf:where(:focus-visible) { outline: none; background: color-mix(in oklab, var(--accent) 16%, var(--surface-2)); } -/* Where the keyboard walk (j/k) is standing. On a session row the cursor is - also what previews it, so it reads exactly like a hover — the preview hue, - deepening to the full fill once the preview lands. A folder row previews - nothing, so it keeps the plain cursor ring. */ -.tree-leaf.is-highlighted { - background: color-mix(in oklab, var(--preview) 12%, var(--surface-2)); -} - -.tree-leaf.is-highlighted[data-status="running"] { - background: color-mix(in oklab, var(--preview) 18%, var(--surface-2)); -} - +/* A folder row previews nothing, so its cursor stays a ring. */ .tree-folder-header.is-highlighted { box-shadow: inset 0 0 0 2px var(--accent-ring); } @@ -1188,25 +1187,24 @@ textarea { outline: none; } -/* The session currently mounted in the content pane. */ -.tree-leaf.is-active, -.tree-leaf.is-active[data-status="running"] { - background: var(--accent-tint); - box-shadow: inset 0 0 0 1px var(--accent-ring); +/* 3. Open in some pane, but not the focused one (issue #33). */ +.tree-leaf:where(.is-open) { + background: color-mix(in oklab, var(--accent) 6%, transparent); +} + +/* 4. The session currently mounted in the content pane. */ +.tree-leaf:where(.is-active) { + background: var(--accent-fill); } .tree-leaf.is-active .leaf-name { color: var(--ink); } -/* The row the content pane is previewing right now — after `.is-active` so a - preview of an already-open session still reads as provisional. */ -.tree-leaf.is-previewing, -.tree-leaf.is-previewing[data-status="running"], -.tree-leaf.is-previewing.is-active, -.tree-leaf.is-previewing.is-highlighted { +/* 5. The row the content pane is previewing right now. Last, so previewing a + session that is already open still reads as provisional. */ +.tree-leaf:where(.is-previewing) { background: var(--preview-tint); - box-shadow: none; } /* Exited sessions recede: dimmed name + an "exited" badge carry status, so it's @@ -3178,13 +3176,6 @@ input.leaf-rename:focus { border-radius: var(--r-1); } -/* sidebar two-tier highlight (issue #33). - .is-active = focused pane (loud, from existing rule); .is-open = open - elsewhere (quiet). */ -.tree-leaf.is-open { - background: color-mix(in oklab, var(--accent) 6%, transparent); -} - /* ========================================================================= multi-view mobile (#35): below 860px only the focused pane is shown — no tab bar, no pane switcher; the sidebar drawer (☰) is the navigation.