feat(sidebar): give the rail a text hierarchy and put colour on state

Every line in the sidebar sat at the same 4.5:1 grey — tab title, branch
line, group header, search placeholder — so the only things that stood
out were twelve identical brand-orange agent discs and twelve copies of
the same `+94 −26`, neither of which says which tab matters.

- Titles rise to a 7:1 floor (AAA) with a cap that keeps the selected
  label its `TEXT_STEP`; captions stay at `TEXT_RESTING`. A new test pins
  the step between them.
- The sidebar gets its own selection ladder (`SIDEBAR_SELECTED` 1.50);
  the window's signed-off 1.30 rung is untouched and its Dracula pin now
  checks the constant rather than the rail's fill.
- Agent avatars rest as a 16% brand tint with the mark in brand ink and
  light up solid only on the active tab or an agent that is waiting.
- Diff counts render in `resting_ink`: same hue, blended toward the
  caption, walked back to the 4.5 floor where needed.
- A group whose rows all share one branch and diff says so once on its
  header; rows with no status yet do not vote, so ⌘T no longer flips the
  group twice while the poll comes back.
- Group headers get 12px above, the count shows only when folded, and
  the two sub-floor `.opacity()` captions are gone. The workspace
  switcher name reads at the headers' weight instead of inheriting the
  title ink.

Claude-Session: https://claude.ai/code/session_01Ak6Lnzf3EGErJZmG51Rxbo
This commit is contained in:
l0ng-ai
2026-09-09 17:41:49 +08:00
parent 6649cccbc7
commit 305535cde3
4 changed files with 325 additions and 44 deletions
+127 -11
View File
@@ -86,6 +86,16 @@ pub mod state {
pub const CURSOR: f32 = 1.70;
pub const TEXT_RESTING: f32 = 4.6;
pub const TEXT_STEP: f32 = 1.4;
/// The sidebar's selection ladder sits one rung above the window's. The
/// window paints a selected row inside a list the user is already looking
/// at; the sidebar paints the one tab out of twenty that owns the pane
/// area, and at 1.30:1 that tint measured as the faintest mark in the
/// column — fainter than a group header's count. `PRESSED` and `CURSOR`
/// climb with it so the ladder keeps its spacing.
pub const SIDEBAR_SELECTED: f32 = 1.50;
pub const SIDEBAR_PRESSED: f32 = 1.75;
pub const SIDEBAR_CURSOR: f32 = 1.92;
}
#[derive(Debug, Clone, Copy)]
@@ -192,9 +202,26 @@ impl Theme {
sidebar,
// Blended, not bisected, so a palette's own softness carries into
// the sidebar — but floored on the fill it is actually painted on
// (`sidebar`, not `background`), because four of the builtins
// land this under 4.5:1 and it is the tab title, not a caption.
sidebar_fg: at_least(mix(fg, bg, 0.28), fg, sidebar, TEXT_FLOOR),
// (`sidebar`, not `background`). This is the tab title, the top
// rung of a three-rung column (title / branch / group header), and
// it is floored at `TITLE_FLOOR` rather than `TEXT_FLOOR` because
// the rung under it, `muted_foreground`, already sits at
// `TEXT_RESTING`: a title at 4.5:1 next to a caption at 4.6:1 is
// the same grey twice, and the column reads as one flat wash with
// nothing to look at first.
sidebar_fg: {
let title = at_least(mix(fg, bg, 0.10), fg, sidebar, TITLE_FLOOR);
// …and capped so the selected label keeps its `TEXT_STEP`
// above it: on a white-on-black palette a 10% blend lands so
// close to `fg` that there is nothing brighter left to step
// to. The floor wins over the cap on a soft palette, where
// the step is taken past `fg` instead (see `stepped_ink`).
let headroom = (contrast(fg, sidebar) / state::TEXT_STEP).max(TITLE_FLOOR);
match headroom > TITLE_FLOOR && contrast(title, sidebar) > headroom {
true => dim(title, sidebar, headroom),
false => title,
}
},
accent: legible_accent(bg, self.accent),
}
}
@@ -298,14 +325,14 @@ impl Theme {
pub fn surfaces(&self) -> Surfaces {
let m = self.neutrals();
let fg = legible_foreground(self.background_color(), self.foreground);
let mut sidebar = self.surface(m.sidebar);
sidebar.selected = raise(sidebar.base, fg, state::SIDEBAR_SELECTED);
sidebar.pressed = raise(sidebar.base, fg, state::SIDEBAR_PRESSED);
sidebar.cursor = raise(sidebar.base, fg, state::SIDEBAR_CURSOR);
sidebar.text_resting = m.sidebar_fg;
sidebar.text_selected = stepped_ink(
sidebar.selected,
sidebar.base,
legible_foreground(self.background_color(), self.foreground),
sidebar.text_resting,
);
sidebar.text_selected =
stepped_ink(sidebar.selected, sidebar.base, fg, sidebar.text_resting);
Surfaces {
window: self.surface(m.background),
sidebar,
@@ -573,6 +600,41 @@ pub(crate) fn wash(surface: u32, tint: u32, target: f32) -> u32 {
const TEXT_FLOOR: f32 = 4.5;
/// The floor for the top rung of a text column whose second rung rests at
/// `TEXT_RESTING`. WCAG AAA's 7:1, which also happens to be the smallest
/// ratio that clears `TEXT_STEP` over a 4.6:1 caption with room to spare on
/// the fills a sidebar is actually painted on.
const TITLE_FLOOR: f32 = 7.0;
/// A semantic ink stepped down to sit beside body text instead of over it.
///
/// `success` and `danger` are cleared to `TEXT_FLOOR` at full chroma, which is
/// right for the one line that says a push failed and wrong for a `+94 26`
/// repeated on every row of a list: twelve saturated numerals become the
/// loudest thing in the column while carrying the least. Blending toward the
/// caption ink they sit next to keeps the hue (green still means added) and
/// takes the shout out, then the blend is walked back toward the full ink
/// only when the surface it lands on cannot carry it at `TEXT_FLOOR`.
pub(crate) fn resting_ink(ink: Hsla, beside: Hsla, surface: Hsla) -> Hsla {
let (ink, beside, surface) = (pack(ink), pack(beside), pack(surface));
let blend = mix(ink, beside, 0.45);
gpui::rgb(at_least(blend, ink, surface, TEXT_FLOOR)).into()
}
/// A brand colour as ink on a theme surface: kept as authored when it already
/// reads there, walked toward black or white only when it does not. What an
/// agent glyph is drawn in once the disc behind it is no longer a solid brand
/// fill — Claude's orange is a fill colour, not a text colour, on a light
/// window, and pure-black Codex vanishes into a dark one.
pub(crate) fn legible_on(surface: Hsla, seed: u32) -> Hsla {
gpui::rgb(legible_ink(pack(surface), seed, ACCENT_FLOOR)).into()
}
fn pack(c: Hsla) -> u32 {
let rgb = crate::terminal::palette::hsla_to_rgb(c);
(rgb.r as u32) << 16 | (rgb.g as u32) << 8 | rgb.b as u32
}
/// Hairlines are separators, not control outlines — the surfaces they divide
/// carry their own fills, so WCAG 1.4.11's 3:1 does not apply and painting them
/// that hard would read as a wireframe. This floor only rescues the palettes
@@ -1632,10 +1694,17 @@ mod tests {
let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap();
let bg = dracula.background_color();
let s = dracula.surfaces();
// The resting rung is checked as `state::SELECTED` on the sidebar
// fill — the surface it was signed off on — rather than as the rail's
// own fill: the rail was lifted off this value on purpose
// (`state::SIDEBAR_SELECTED`) once the tab that owns the pane area
// measured as the faintest mark in its own column, and this pin is
// here to catch the constant drifting, not that decision.
let fg = legible_foreground(bg, dracula.foreground);
for (what, now, legacy) in [
(
"resting",
s.sidebar.selected,
raise(s.sidebar.base, fg, state::SELECTED),
mix(bg, dracula.foreground, 0.12),
),
("cursor", s.window.cursor, mix(bg, dracula.foreground, 0.17)),
@@ -1936,13 +2005,60 @@ mod tests {
);
}
#[test]
fn sidebar_title_is_stepped_off_its_caption() {
// The three rungs of a sidebar row — title, branch line, group
// header — all sat at the same 4.5:1 grey once; this is the guard
// against that column flattening again. The caption is dimmed on the
// window and painted on the sidebar, so measure it where it lands.
for t in builtins() {
let m = t.neutrals();
let step = contrast(m.sidebar_fg, m.muted_foreground);
assert!(
step >= state::TEXT_STEP - 0.01,
"{}: title {:#08x} is only {step:.2}:1 off the caption {:#08x}",
t.id,
m.sidebar_fg,
m.muted_foreground
);
}
}
#[test]
fn resting_semantic_ink_keeps_the_text_floor() {
for t in builtins() {
let m = t.neutrals();
let sem = t.semantics();
let beside: Hsla = gpui::rgb(m.muted_foreground).into();
for (name, ink) in [("success", sem.success.ink), ("danger", sem.danger.ink)] {
for (surface_name, surface) in [
("window", m.background),
("sidebar", m.sidebar),
("popover", m.popover),
] {
let resting = pack(resting_ink(
gpui::rgb(ink).into(),
beside,
gpui::rgb(surface).into(),
));
let ratio = contrast(resting, surface);
assert!(
ratio >= TEXT_FLOOR - 0.02,
"{}/{surface_name}: resting {name} {resting:#08x} is only {ratio:.2}:1",
t.id
);
}
}
}
}
#[test]
fn sidebar_text_reads_on_the_fill_it_is_painted_on() {
for t in builtins() {
let m = t.neutrals();
let ratio = contrast(m.sidebar_fg, m.sidebar);
assert!(
ratio >= TEXT_FLOOR - 0.01,
ratio >= TITLE_FLOOR - 0.01,
"{}: sidebar text {:#08x} is only {ratio:.2}:1 on the sidebar fill {:#08x}",
t.id,
m.sidebar_fg,
+1
View File
@@ -2916,6 +2916,7 @@ impl Tty7App {
tab.status,
tab.unread,
tab.ssh,
picked,
ROW_AVATAR,
cx,
))
+166 -27
View File
@@ -15,7 +15,7 @@ use std::path::{Path, PathBuf};
use crate::core::config::{Config, SidebarGrouping};
use crate::core::group_key::{GroupKey, collapse_key};
use crate::terminal::git_status::GitStatusCache;
use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App};
use crate::ui::app::{TITLE_BAR_HEIGHT, Tab, Tty7App};
use crate::ui::hints::tab_badge_label;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::reorder::{self, Reorder, Surface};
@@ -61,6 +61,16 @@ mod row_metrics {
}
}
/// The branch a whole group shares, lifted off its rows and onto its header.
struct SharedGit {
status: crate::terminal::git_status::GitStatus,
/// Where a click on the counts opens the diff overlay, if the setting
/// allows one.
click: Option<(crate::ui::host_ops::HostId, PathBuf)>,
/// Every row the group counts, drawn or folded away.
rows: Vec<usize>,
}
/// What a sidebar row rendered, next to what it had to leave out, so the
/// hover card can be built by comparison instead of deriving the same strings
/// a second time — the two derivations have to agree, and the shortest way to
@@ -248,6 +258,18 @@ impl Tty7App {
..font.clone()
};
let rem = window.rem_size().as_f32();
// The diff counts in their resting weight: green still means added,
// but twelve of them down a column no longer outshout the titles.
let added_ink = crate::ui::presets::resting_ink(
cx.theme().success,
cx.theme().muted_foreground,
cx.theme().sidebar,
);
let removed_ink = crate::ui::presets::resting_ink(
cx.theme().danger,
cx.theme().muted_foreground,
cx.theme().sidebar,
);
let rendered = |ix: &usize| !visible_by_section[*ix].is_empty();
// Every section that owns a key — a repo root or a custom name — draws
// a header, and a header is what there is to grab, so these are the
@@ -328,6 +350,36 @@ impl Tty7App {
visible.len(),
pointer,
);
// A group whose rows all sit on the same branch with the same
// diff says so once, on its header, instead of once per row.
// Four copies of `pr-818 +94 26` under one heading describe the
// repo, not the tabs, and being the only coloured text in the
// column they were also the loudest thing in it. Read off every
// row the group counts rather than the ones it draws, so a folded
// group still names its branch.
let shared_git: Option<SharedGit> = section.name.as_ref().and_then(|_| {
let rows = &visible_by_section[group_ix];
if rows.len() < 2 {
return None;
}
// Only rows that *have* a status get a vote. A tab that was
// just opened has none until its shell reports a directory
// and the poll comes back; counting it as a disagreement
// pulled the branch off the header and grew a branch line
// under every sibling for the half second it took, then
// folded them all back — the column jumped twice for every
// ⌘T. Unknown is not different; it is not yet known.
let mut known = rows
.iter()
.filter_map(|&i| Some((i, self.tabs[i].git_status(Some(window), cx)?)));
let (first, status) = known.next()?;
let same = known.all(|(_, other)| other == status);
same.then(|| SharedGit {
status,
click: git_click(&self.tabs[first], window, cx),
rows: rows.clone(),
})
});
for (slot, i) in visible.into_iter().enumerate() {
let badge_pos = badge_pos[i];
let tab = &self.tabs[i];
@@ -336,14 +388,7 @@ impl Tty7App {
let agent = tab.agent(cx);
let agent_status = tab.agent_status(cx);
let agent_unread = tab.agent_unread_count(cx);
let git_cwd = diff_click_cwd(
cx.global::<Config>(),
tab.pane.focused_or_first(window, cx).and_then(|leaf| {
let view = leaf.read(cx);
let cwd = view.git_status_cwd()?.to_path_buf();
Some((view.host_id(), cwd))
}),
);
let git_cwd = git_click(tab, window, cx);
let badge_extra = if show_badges && badge_pos < 9 {
row_metrics::BADGE + row_metrics::GAP
} else {
@@ -426,7 +471,11 @@ impl Tty7App {
};
let mut branch_shown: Option<(SharedString, SharedString, u32, u32)> = None;
let mut cwd_shown: Option<(SharedString, SharedString)> = None;
let git_line = tab.git_status(Some(window), cx).map(|g| {
let git_line = match shared_git.is_some() {
true => None,
false => tab.git_status(Some(window), cx),
}
.map(|g| {
let mut line = h_flex()
.id(("sidebar-git", i))
.w_full()
@@ -521,16 +570,13 @@ impl Tty7App {
)
});
if g.added > 0 {
counts = counts.child(
div()
.text_color(cx.theme().success)
.child(format!("+{}", g.added)),
);
counts = counts
.child(div().text_color(added_ink).child(format!("+{}", g.added)));
}
if g.removed > 0 {
counts = counts.child(
div()
.text_color(cx.theme().danger)
.text_color(removed_ink)
.child(format!("{}", g.removed)),
);
}
@@ -541,7 +587,7 @@ impl Tty7App {
// Outside a repo there is no branch line; the second line then
// carries the compressed cwd with its root marker, so a tab
// whose title is just a shell name still says where it lives.
if git_line.is_none() {
if git_line.is_none() && shared_git.is_none() {
cwd_shown = tab
.pane
.focused_or_first(window, cx)
@@ -582,8 +628,8 @@ impl Tty7App {
// Colors are captured by value so the tooltip builder (which
// borrows no app state) can style the card on its own.
let muted = cx.theme().muted_foreground;
let success = cx.theme().success;
let danger = cx.theme().danger;
let success = added_ink;
let danger = removed_ink;
let label_region = match rename_input {
Some(input) => div()
@@ -707,7 +753,7 @@ impl Tty7App {
.items_center()
.gap_1p5()
.text_xs()
.text_color(cx.theme().muted_foreground.opacity(0.8))
.text_color(cx.theme().muted_foreground)
.child(div().flex_1().min_w_0().truncate().child(cwd)),
)
})
@@ -795,6 +841,7 @@ impl Tty7App {
agent_status,
agent_unread,
ssh_dot,
is_active,
22.,
cx,
))
@@ -951,7 +998,10 @@ impl Tty7App {
.gap_1p5()
.pl_2()
.pr_1p5()
.pt_1p5()
// More above a heading than below it: the 12px is the
// generous interval in a column whose rows sit 2px apart,
// and it is what makes a group a group without a box.
.pt(px(12.))
.pb_0p5()
.text_size(px(11.))
.text_color(cx.theme().muted_foreground)
@@ -1015,12 +1065,84 @@ impl Tty7App {
.child(label)
.into_any_element(),
})
.child(
div()
.flex_shrink_0()
.text_color(cx.theme().muted_foreground.opacity(0.7))
.child(row_count.to_string()),
);
.when_some(shared_git, |bar, shared| {
let SharedGit {
status,
click,
rows,
} = shared;
let mut line = h_flex()
.id(("sidebar-group-git", group_ix))
.flex_shrink(2.)
.min_w_0()
.items_center()
.gap_1p5()
.child(
gpui::svg()
.path("icons/git-branch.svg")
.flex_shrink_0()
.size(px(row_metrics::BRANCH_ICON))
.text_color(cx.theme().muted_foreground),
)
.child(div().min_w_0().truncate().child(status.branch.clone()));
if status.added > 0 || status.removed > 0 {
let mut counts = h_flex()
.id(("sidebar-group-diff", group_ix))
.flex_shrink_0()
.items_center()
.gap_1p5()
.when_some(click, |counts, (host, cwd)| {
counts
.cursor_pointer()
.hover(|s| s.underline())
.on_mouse_down(
MouseButton::Left,
cx.listener(
move |this, _: &MouseDownEvent, window, cx| {
cx.stop_propagation();
// The overlay opens over the
// active tab; make sure that
// is one of this group's,
// the same way a row's counts
// activate their row first.
if !rows.contains(&this.active)
&& let Some(&first) = rows.first()
{
this.activate(first, window, cx);
}
this.toggle_diff_overlay(
host,
cwd.clone(),
window,
cx,
);
},
),
)
});
if status.added > 0 {
counts = counts.child(
div()
.text_color(added_ink)
.child(format!("+{}", status.added)),
);
}
if status.removed > 0 {
counts = counts.child(
div()
.text_color(removed_ink)
.child(format!("{}", status.removed)),
);
}
line = line.child(counts);
}
bar.child(div().flex_1()).child(line)
})
// The count is redundant while the rows are on screen; it
// is what a shut group has instead of them.
.when(folded, |bar| {
bar.child(div().flex_shrink_0().child(row_count.to_string()))
});
// Renaming is offered on a menu rather than a double click:
// the first click of a double would fold the group, so the
// name would be edited on a box that just shut. A repo group
@@ -1908,6 +2030,23 @@ fn group_names(roots: &[&PathBuf]) -> Vec<String> {
/// tree, and "Open diff preview from sidebar counts" turning one of them into
/// plain text while the other stayed clickable would be a setting that half
/// works.
/// Where a click on a tab's diff counts opens the overlay: the focused pane's
/// repo, when the setting allows a preview at all.
fn git_click(
tab: &Tab,
window: &Window,
cx: &gpui::App,
) -> Option<(crate::ui::host_ops::HostId, PathBuf)> {
diff_click_cwd(
cx.global::<Config>(),
tab.pane.focused_or_first(window, cx).and_then(|leaf| {
let view = leaf.read(cx);
let cwd = view.git_status_cwd()?.to_path_buf();
Some((view.host_id(), cwd))
}),
)
}
pub(crate) fn diff_click_cwd<T>(cfg: &Config, target: Option<T>) -> Option<T> {
cfg.sidebar_diff_preview.then_some(target).flatten()
}
+31 -6
View File
@@ -1006,15 +1006,22 @@ impl Tty7App {
.bg(cx.theme().secondary)
.text_size(px(10.))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().muted_foreground)
.child(monogram),
)
.child(
// Chrome, not a row: the tile inherits the
// rail's title ink, which now belongs to the
// tabs. The workspace name reads at the group
// headers' weight so the one dark line in
// the column stays the tab in front.
div()
.flex_shrink(1.)
.min_w_0()
.truncate()
.text_size(px(12.5))
.font_weight(FontWeight::SEMIBOLD)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(current.clone())),
)
.child(
@@ -1271,6 +1278,7 @@ impl Tty7App {
status: Option<crate::core::cli_agent::AgentStatus>,
unread: usize,
ssh: Option<u32>,
lit: bool,
size: f32,
cx: &App,
) -> gpui::AnyElement {
@@ -1293,16 +1301,29 @@ impl Tty7App {
Some(state) => format!("{}{state}", agent.display_name()),
None => agent.display_name().to_string(),
};
// The brand colour is identity, not state, and identity is
// not what a column of twenty tabs needs shouted: a solid
// orange disc on every Claude row made the brand the loudest
// mark in the sidebar while a seven-pixel dot carried the one
// thing that changes. So the disc rests as a tint of its
// brand with the mark drawn in the brand's own ink, and only
// lights up solid where the eye is meant to land — the tab in
// front, and an agent that has stopped to ask something.
let accent = agent.accent_rgb();
let lit = lit || hollow;
let surface = cx.theme().background;
base.relative()
.rounded_full()
.bg(gpui::rgb(agent.accent_rgb()))
.when(lit, |d| d.bg(gpui::rgb(accent)))
.when(!lit, |d| {
d.bg(gpui::Hsla::from(gpui::rgb(accent)).opacity(0.16))
})
// Codex and Grok are both pure black, which is the window
// fill on a dark theme — the disc dissolves and leaves the
// glyph floating. A hairline keeps it a disc in any theme.
.when(
crate::ui::presets::needs_edge(agent.accent_rgb(), cx.theme().background),
|d| d.border_1().border_color(cx.theme().border),
)
.when(crate::ui::presets::needs_edge(accent, surface), |d| {
d.border_1().border_color(cx.theme().border)
})
.child(
gpui::svg()
.path(agent.icon_path())
@@ -1311,7 +1332,10 @@ impl Tty7App {
// the mark's colour comes from the agent rather
// than from the file. The tray icon reads the same
// answer.
.text_color(gpui::rgb(agent.icon_rgb())),
.text_color(match lit {
true => gpui::Hsla::from(gpui::rgb(agent.icon_rgb())),
false => crate::ui::presets::legible_on(surface, accent),
}),
)
.when_some(dot, |b, dot| b.child(dot))
.tooltip(move |window, cx| {
@@ -1935,6 +1959,7 @@ impl Tty7App {
agent_status,
agent_unread,
None,
is_active,
18.,
cx,
))