From c534cee7cc5b9f420ab026d59af8885fb872911e Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 12:31:14 +0100 Subject: [PATCH 1/2] feat(maestro): palette of named colour roles for the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every colour the TUI painted on its own chrome was a named ratatui colour spelled out at the use site, so nothing could be themed and the selected card had broken contrast: the highlighted card's black-on-cyan base was patched with an activity colour chosen for the dark card background, leaving AwaitingInput yellow on light blue. Roles now live in `palette.rs`, named for meaning. A palette holds one role set per surface — card and selection — plus the panel border in its focused and unfocused forms, a focus accent, a group-row style for the sidebar grouping, the status-bar mode indicators and error text. Each surface carries its own activity colours, so the selected card's dot keeps carrying state without wearing a colour picked for another background. Values are `Color::Rgb`, so contrast is asserted rather than left to the terminal theme: every role clears WCAG AA on its own surface, and a sidebar test fails if a card-surface state colour is painted onto the selection background. PTY cell colours stay a passthrough of what the program in the pane emits — they are not chrome and not a palette role. --- crates/maestro-tui/src/draft.rs | 4 +- crates/maestro-tui/src/lib.rs | 1 + crates/maestro-tui/src/palette.rs | 338 ++++++++++++++++++++++++++++++ crates/maestro-tui/src/render.rs | 18 +- crates/maestro-tui/src/sidebar.rs | 126 +++++++---- docs/SIDEBAR.md | 31 ++- 6 files changed, 449 insertions(+), 69 deletions(-) create mode 100644 crates/maestro-tui/src/palette.rs diff --git a/crates/maestro-tui/src/draft.rs b/crates/maestro-tui/src/draft.rs index ab5db6a..90fb845 100644 --- a/crates/maestro-tui/src/draft.rs +++ b/crates/maestro-tui/src/draft.rs @@ -41,7 +41,7 @@ use chrono::{DateTime, Utc}; use maestro_config::{builtin_presets, custom_preset}; use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec}; use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; @@ -1510,7 +1510,7 @@ impl DraftPane { f.render_widget( Paragraph::new(Line::from(Span::styled( format!("error: {error}"), - Style::default().fg(Color::Red), + crate::palette::active().error_style(), ))), row, ); diff --git a/crates/maestro-tui/src/lib.rs b/crates/maestro-tui/src/lib.rs index c7cfc56..7e5d0d6 100644 --- a/crates/maestro-tui/src/lib.rs +++ b/crates/maestro-tui/src/lib.rs @@ -13,6 +13,7 @@ pub mod fast_forward; pub mod input; pub mod keymap; pub mod overlays; +pub mod palette; pub mod pane; pub mod projects; pub mod render; diff --git a/crates/maestro-tui/src/palette.rs b/crates/maestro-tui/src/palette.rs new file mode 100644 index 0000000..cca2413 --- /dev/null +++ b/crates/maestro-tui/src/palette.rs @@ -0,0 +1,338 @@ +//! Named colour roles for the TUI. +//! +//! Every colour the TUI paints on its own chrome comes from here, named for +//! what it means rather than what it looks like: the card surface, the +//! selection surface, the panel border in its focused and unfocused forms, +//! the text weights (`fg` / `muted` / `dim`), and one role per +//! [`ActivityState`]. Call sites ask the palette for a role; they never spell +//! a colour out. +//! +//! Values are [`Color::Rgb`], so contrast is a property of the palette rather +//! than of the user's terminal theme — and can therefore be asserted (see +//! [`contrast_ratio`] and this module's tests). +//! +//! 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). +//! +//! One palette ships today, reached through [`active`]. Selecting between +//! presets from config is a separate issue; [`active`] is the seam it +//! replaces. +//! +//! Out of scope: PTY cell colours. Those belong to the program running in the +//! pane and pass through `pane::translate_color` verbatim. + +use maestro_protocol::ActivityState; +use ratatui::style::{Color, Modifier, Style}; + +/// A background and the foreground roles legible on it. +/// +/// The sidebar has two: the card surface every card sits on, and the +/// selection surface the highlighted card swaps in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Surface { + /// The surface itself. + pub bg: Color, + /// Primary text on this surface. + pub fg: Color, + /// Secondary text — the blocked-work, exited, detail and command + /// fragments of a card's meta line. + pub muted: Color, + /// The quietest legible text on this surface, for decoration that must + /// not compete with content. + pub dim: Color, + /// `ActivityState::Active`. + pub active: Color, + /// `ActivityState::Idle`. + pub idle: Color, + /// `ActivityState::AwaitingInput`. + pub awaiting: Color, + /// `ActivityState::Error`. + pub error: Color, +} + +impl Surface { + /// The bare surface: a background with no foreground override. + pub fn base(&self) -> Style { + Style::default().bg(self.bg) + } + + /// Primary text on this surface. + pub fn text_style(&self) -> Style { + Style::default().bg(self.bg).fg(self.fg) + } + + /// Secondary text on this surface. + pub fn muted_style(&self) -> Style { + Style::default().bg(self.bg).fg(self.muted) + } + + /// The quietest text on this surface. + pub fn dim_style(&self) -> Style { + Style::default().bg(self.bg).fg(self.dim) + } + + /// The colour carrying `state` on this surface. + pub fn activity(&self, state: ActivityState) -> Color { + match state { + ActivityState::Active => self.active, + ActivityState::Idle => self.idle, + ActivityState::AwaitingInput => self.awaiting, + ActivityState::Error => self.error, + } + } + + /// Every foreground role on this surface, paired with its role name. + /// Used by the contrast tests, and by anything that needs to reason about + /// the surface as a whole. + pub fn roles(&self) -> [(&'static str, Color); 7] { + [ + ("fg", self.fg), + ("muted", self.muted), + ("dim", self.dim), + ("active", self.active), + ("idle", self.idle), + ("awaiting", self.awaiting), + ("error", self.error), + ] + } +} + +/// The full set of colour roles the TUI paints with. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Palette { + /// The surface an unselected card sits on. + pub card: Surface, + /// The surface the highlighted card swaps in. + pub selection: Surface, + /// Panel border while that panel holds focus. + pub border_focused: Color, + /// Panel border while it does not. + pub border_unfocused: Color, + /// Focus affordances that are not the border itself — markers, titles and + /// the like on the panel that holds focus. + pub focus_accent: Color, + /// Sidebar group headings. + pub group_row: Color, + /// Status-bar mode indicator, normal mode. + pub mode_normal_bg: Color, + pub mode_normal_fg: Color, + /// Status-bar mode indicator, insert mode. + pub mode_insert_bg: Color, + pub mode_insert_fg: Color, + /// Error text outside a card — form errors, failed actions. + pub error_text: Color, +} + +impl Palette { + /// The one palette that ships today. + pub const DEFAULT: Palette = Palette { + card: Surface { + bg: Color::Rgb(0x14, 0x16, 0x1b), + fg: Color::Rgb(0xd6, 0xdc, 0xe5), + muted: Color::Rgb(0x9a, 0xa3, 0xb2), + dim: Color::Rgb(0x6b, 0x72, 0x80), + active: Color::Rgb(0x3f, 0xb9, 0x50), + idle: Color::Rgb(0x8b, 0x94, 0x9e), + awaiting: Color::Rgb(0xd2, 0x99, 0x22), + error: Color::Rgb(0xf8, 0x51, 0x49), + }, + selection: Surface { + bg: Color::Rgb(0x2b, 0x3f, 0x6e), + fg: Color::Rgb(0xf0, 0xf4, 0xfa), + muted: Color::Rgb(0xc0, 0xc9, 0xda), + dim: Color::Rgb(0x93, 0xa0, 0xbc), + active: Color::Rgb(0x7e, 0xe7, 0x87), + idle: Color::Rgb(0xb6, 0xc0, 0xd4), + awaiting: Color::Rgb(0xf2, 0xcc, 0x60), + error: Color::Rgb(0xff, 0x94, 0x92), + }, + border_focused: Color::Rgb(0x58, 0xa6, 0xff), + border_unfocused: Color::Rgb(0x30, 0x36, 0x3d), + focus_accent: Color::Rgb(0x58, 0xa6, 0xff), + group_row: Color::Rgb(0x8f, 0xa3, 0xc8), + mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff), + mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11), + mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22), + mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11), + error_text: Color::Rgb(0xf8, 0x51, 0x49), + }; + + /// The surface a card sits on: the selection surface when it is the + /// highlighted card, the card surface otherwise. + pub fn surface(&self, selected: bool) -> &Surface { + if selected { + &self.selection + } else { + &self.card + } + } + + /// Border style for a panel, by whether it holds focus. + pub fn border_style(&self, focused: bool) -> Style { + if focused { + Style::default().fg(self.border_focused) + } else { + Style::default().fg(self.border_unfocused) + } + } + + /// Style for a sidebar group heading. + pub fn group_row_style(&self) -> Style { + Style::default() + .fg(self.group_row) + .add_modifier(Modifier::BOLD) + } + + /// Style for the status bar's mode indicator. + pub fn mode_style(&self, insert: bool) -> Style { + if insert { + Style::default() + .fg(self.mode_insert_fg) + .bg(self.mode_insert_bg) + } else { + Style::default() + .fg(self.mode_normal_fg) + .bg(self.mode_normal_bg) + } + } + + /// Style for error text outside a card. + pub fn error_style(&self) -> Style { + Style::default().fg(self.error_text) + } +} + +impl Default for Palette { + fn default() -> Self { + Self::DEFAULT + } +} + +/// The palette in force. Config-driven preset selection lands here. +pub fn active() -> Palette { + Palette::DEFAULT +} + +/// 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. +pub fn relative_luminance(color: Color) -> Option { + let Color::Rgb(r, g, b) = color else { + return None; + }; + fn channel(v: u8) -> f64 { + let v = v as f64 / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + } + Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)) +} + +/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0 +/// (black on white). `None` if either colour is not RGB. +pub fn contrast_ratio(a: Color, b: Color) -> Option { + let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + Some((hi + 0.05) / (lo + 0.05)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const STATES: [ActivityState; 4] = [ + ActivityState::Active, + ActivityState::Idle, + ActivityState::AwaitingInput, + ActivityState::Error, + ]; + + /// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is + /// decoration and only has to clear the 3:1 non-text threshold. + #[test] + fn every_role_is_legible_on_its_own_surface() { + let p = active(); + for (name, surface) in [("card", &p.card), ("selection", &p.selection)] { + for (role, color) in surface.roles() { + let ratio = contrast_ratio(surface.bg, color) + .unwrap_or_else(|| panic!("{name}.{role} must be an RGB value")); + let floor = if role == "dim" { 3.0 } else { 4.5 }; + assert!( + ratio >= floor, + "{name}.{role} contrast {ratio:.2} is below {floor}" + ); + } + } + } + + /// The selection background exists to be seen as a different surface, not + /// merely a slightly different shade of the card. + #[test] + fn selection_background_is_distinguishable_from_the_card_background() { + let p = active(); + let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap(); + assert!( + ratio >= 1.6, + "card and selection backgrounds differ by only {ratio:.2}" + ); + } + + /// The bug this palette fixes: a state colour chosen for the dark card + /// background is not legible on the selection background, so the + /// selection surface carries its own — distinct — state colours. + #[test] + fn selection_state_colours_are_not_the_card_state_colours() { + let p = active(); + for state in STATES { + assert_ne!( + p.card.activity(state), + p.selection.activity(state), + "{state:?} must have a selection-specific colour" + ); + } + } + + /// Other panels' roles are RGB too, so a preset swap moves all of them. + #[test] + fn chrome_roles_are_rgb_values() { + let p = active(); + for (name, color) in [ + ("border_focused", p.border_focused), + ("border_unfocused", p.border_unfocused), + ("focus_accent", p.focus_accent), + ("group_row", p.group_row), + ("mode_normal_bg", p.mode_normal_bg), + ("mode_normal_fg", p.mode_normal_fg), + ("mode_insert_bg", p.mode_insert_bg), + ("mode_insert_fg", p.mode_insert_fg), + ("error_text", p.error_text), + ] { + assert!( + relative_luminance(color).is_some(), + "{name} must be an RGB value" + ); + } + // The mode indicator paints fg on bg; both pairs must stay readable. + for (name, fg, bg) in [ + ("normal", p.mode_normal_fg, p.mode_normal_bg), + ("insert", p.mode_insert_fg, p.mode_insert_bg), + ] { + let ratio = contrast_ratio(fg, bg).unwrap(); + assert!(ratio >= 4.5, "mode {name} contrast {ratio:.2} is below 4.5"); + } + } + + #[test] + fn contrast_ratio_matches_known_values() { + let black = Color::Rgb(0, 0, 0); + let white = Color::Rgb(255, 255, 255); + assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01); + assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01); + assert_eq!(contrast_ratio(Color::Cyan, white), None); + } +} diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index 12673cb..035dabf 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -3,7 +3,7 @@ //! hit-test mouse clicks. use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Style}; +use ratatui::style::Style; use ratatui::text::Span; use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; @@ -11,6 +11,7 @@ use ratatui::Frame; use crate::input::Region; use crate::keymap::Mode; use crate::overlays::{HelpOverlay, KillConfirm, Toast}; +use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind}; @@ -193,14 +194,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { map } -/// Border style for a panel based on whether it currently has focus. -/// Focused = cyan; unfocused = the terminal default. +/// Border style for a panel based on whether it currently has focus. Both +/// forms are palette roles (`border_focused` / `border_unfocused`). fn focus_style(focused: bool) -> Style { - if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default() - } + palette::active().border_style(focused) } /// Render the always-visible status bar at the bottom of the frame. Surfaces @@ -219,10 +216,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) { Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit", Mode::Insert => "^a: back to normal", }; - let mode_style = match state.mode { - Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan), - Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow), - }; + let mode_style = palette::active().mode_style(state.mode == Mode::Insert); let line = ratatui::text::Line::from(vec![ Span::styled(format!(" {mode_label} "), mode_style), Span::raw(format!(" {focus_label} • {hint}")), diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 0fe2dc3..4f606f3 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -16,6 +16,8 @@ use ratatui::widgets::{Block, Borders, Paragraph}; use ratatui::Frame; use uuid::Uuid; +use crate::palette; + /// Time-in-state alone earns a meta line only once the session has sat in /// its current activity state this long (docs/SIDEBAR.md). const META_LINE_AFTER_SECS: i64 = 30; @@ -30,16 +32,12 @@ const META_INDENT: &str = " "; /// Spawn-similar and kill, shown on the highlighted card's top-right corner. const ACTIONS: &str = "⧉ ✕"; -/// Every card sits on this background so cards read as cards against the -/// panel; the highlighted card swaps it for the focus background instead -/// (`Modifier::REVERSED` made the card unreadable against a background). -const CARD_BG: Color = Color::Black; +/// Every card sits on the palette's card surface so cards read as cards +/// against the panel; the highlighted card swaps in the selection surface +/// instead (`Modifier::REVERSED` made the card unreadable against a +/// background). fn card_base(highlighted: bool) -> Style { - if highlighted { - Style::default().fg(Color::Black).bg(Color::Cyan) - } else { - Style::default().bg(CARD_BG) - } + palette::active().surface(highlighted).text_style() } /// Renderable sidebar state. @@ -60,11 +58,7 @@ impl Sidebar { /// the v1 layout — a single vertical rule keeps the panel boundary /// without stealing space. pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) { - let border_style = if focused { - Style::default().fg(Color::Cyan) - } else { - Style::default() - }; + let border_style = palette::active().border_style(focused); let block = Block::default() .borders(Borders::RIGHT) .border_style(border_style); @@ -121,10 +115,7 @@ impl Sidebar { ); let used: usize = spans.iter().map(|sp| sp.width()).sum(); if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) { - spans.push(Span::styled( - " ".repeat(fill), - Style::default().bg(base.bg.unwrap_or(Color::Reset)), - )); + spans.push(Span::styled(" ".repeat(fill), base)); } if highlighted { spans.push(Span::styled(ACTIONS, base)); @@ -149,9 +140,10 @@ impl Sidebar { { return None; } - let base = card_base(self.highlight == Some(s.id)); - let dot_style = base.patch(Style::default().fg(activity_color(s.activity))); - let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset)); + let highlighted = self.highlight == Some(s.id); + let base = card_base(highlighted); + let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted))); + let muted = palette::active().surface(highlighted).muted_style(); let mut spans = vec![ Span::styled(META_INDENT, base), Span::styled("●", dot_style), @@ -184,15 +176,12 @@ impl Sidebar { } } -/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state -/// word is never spelled out — this colour carries it. -fn activity_color(state: ActivityState) -> Color { - match state { - ActivityState::Active => Color::Green, - ActivityState::Idle => Color::DarkGray, - ActivityState::AwaitingInput => Color::Yellow, - ActivityState::Error => Color::Red, - } +/// Palette role carrying an [`ActivityState`], per docs/SIDEBAR.md. The +/// 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) -> Color { + palette::active().surface(highlighted).activity(state) } /// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours, @@ -246,8 +235,7 @@ fn fit_to_width(spans: Vec>, max: usize) -> Vec> { fn pad_to_width(mut spans: Vec>, width: usize, base: Style) -> Vec> { let used: usize = spans.iter().map(|sp| sp.width()).sum(); if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) { - let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset)); - spans.push(Span::styled(" ".repeat(fill), pad_style)); + spans.push(Span::styled(" ".repeat(fill), base)); } spans } @@ -474,12 +462,13 @@ mod tests { /// is never spelled out (docs/SIDEBAR.md colour table). #[test] fn meta_line_dot_uses_the_contract_colour_per_state() { - for (state, expected) in [ - (ActivityState::Active, Color::Green), - (ActivityState::Idle, Color::DarkGray), - (ActivityState::AwaitingInput, Color::Yellow), - (ActivityState::Error, Color::Red), + for state in [ + ActivityState::Active, + ActivityState::Idle, + ActivityState::AwaitingInput, + ActivityState::Error, ] { + let expected = palette::active().card.activity(state); let mut s = session_named(1, "claude"); s.activity = state; s.activity_since = Utc::now() - Duration::seconds(120); @@ -495,6 +484,54 @@ mod tests { } } + /// The selected card keeps carrying its state on the dot, 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. + #[test] + fn selected_card_never_paints_a_card_state_colour_on_the_selection_background() { + let p = palette::active(); + for state in [ + ActivityState::Active, + ActivityState::Idle, + ActivityState::AwaitingInput, + ActivityState::Error, + ] { + let mut s = session_named(1, "claude"); + s.activity = state; + s.activity_since = Utc::now() - Duration::seconds(120); + let sb = Sidebar { + sessions: vec![s], + highlight: Some(Uuid::from_u128(1)), + attached: Default::default(), + }; + let buf = draw(&sb, 28, 8); + let on_selection = |fg: Color| { + (0..buf.area.height).any(|y| { + (0..buf.area.width).any(|x| { + let st = buf[(x, y)].style(); + st.bg == Some(p.selection.bg) && st.fg == Some(fg) + }) + }) + }; + assert!( + !on_selection(p.card.activity(state)), + "{state:?}: card surface colour {:?} painted onto the selection background", + p.card.activity(state) + ); + assert!( + on_selection(p.selection.activity(state)), + "{state:?}: the dot 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"); + assert!( + ratio >= 4.5, + "{state:?} on the selection background is only {ratio:.2}:1" + ); + } + } + /// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line, /// even freshly dead, with the muted `exited` fragment. #[test] @@ -740,20 +777,21 @@ mod tests { let claude_y = all.iter().position(|l| l.contains("claude")).unwrap(); let bash_y = all.iter().position(|l| l.contains("bash")).unwrap(); + let p = palette::active(); assert_eq!( buf[(0, claude_y as u16)].style().bg, - Some(Color::Cyan), - "focused card must sit on the focus background" + Some(p.selection.bg), + "focused card must sit on the selection surface" ); assert_eq!( buf[(0, claude_y as u16)].style().fg, - Some(Color::Black), - "focused card text must stay readable on cyan" + Some(p.selection.fg), + "focused card text must use the selection foreground role" ); assert_eq!( buf[(0, bash_y as u16)].style().bg, - Some(Color::Black), - "unfocused cards must still have a card background" + Some(p.card.bg), + "unfocused cards must still sit on the card surface" ); // The panel itself (header row) stays on the terminal default. assert!( @@ -776,7 +814,7 @@ mod tests { for x in 0..27 { assert_eq!( buf[(x, y)].style().bg, - Some(Color::Black), + Some(palette::active().card.bg), "gap in card background at col {x}" ); } diff --git a/docs/SIDEBAR.md b/docs/SIDEBAR.md index 82b0522..840c705 100644 --- a/docs/SIDEBAR.md +++ b/docs/SIDEBAR.md @@ -68,21 +68,30 @@ web has always surfaced this; the TUI may omit it). The state word is never spelled out — the colour carries it. -| Activity state | Web token | ratatui colour | -| ---------------- | --------- | ----------------- | -| `Active` | `--live` | `Color::Green` | -| `Idle` | `--muted` | `Color::DarkGray` | -| `AwaitingInput` | `--warn` | `Color::Yellow` | -| `Error` | `--danger`| `Color::Red` | +| Activity state | Web token | TUI palette role | +| ---------------- | --------- | ---------------- | +| `Active` | `--live` | `active` | +| `Idle` | `--muted` | `idle` | +| `AwaitingInput` | `--warn` | `awaiting` | +| `Error` | `--danger`| `error` | Web tokens resolve per theme automatically (`styles.css` defines dark, light, -and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui -named colours above; do not introduce RGB values or a new palette module for -this. +and `prefers-color-scheme` variants for each). The TUI names roles, not +colours: a role's value comes from the active palette +(`maestro-tui/src/palette.rs`), never from a colour spelled out at the use +site. + +A palette holds one role set per **surface** — the card surface every card +sits on, and the selection surface the highlighted card swaps in — so each +state role resolves to the value picked for the surface it is painted on. The +highlighted card therefore takes its state colours from the selection +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. The duration text inherits its state's colour; the blocked-work, exited and -command fragments use the muted colour (`--faint` / default style), since the -⚠ glyph already signals severity. +command fragments use the surface's `muted` role, since the ⚠ glyph already +signals severity. ## Duration format From bc95d16a985b56aefb143f664ad0791c0bb79036 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Tue, 25 Aug 2026 12:31:46 +0100 Subject: [PATCH 2/2] docs(maestro): note PTY colours are passthrough, not palette roles --- crates/maestro-tui/src/pane.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/maestro-tui/src/pane.rs b/crates/maestro-tui/src/pane.rs index fd9593c..552ae1c 100644 --- a/crates/maestro-tui/src/pane.rs +++ b/crates/maestro-tui/src/pane.rs @@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style { s } +/// PTY cell colours pass through verbatim: they belong to the program +/// running in the pane, not to the TUI's own chrome, so they are deliberately +/// not palette roles (see `crate::palette`). fn translate_color(c: vt100::Color) -> Option { match c { vt100::Color::Default => None,