fix(theme): give a pane divider its own, lighter weight (#771)

One hairline value served every line in the app: the outline that closes a
menu, tooltip or card floating over other content, the rule under a header, and
the seam where the sidebar meets the terminal. Those are not the same job. The
first two are the only thing saying where an edge is; the last runs between two
panes that already carry their own fills, so painting it at full weight makes a
workspace read as boxes bolted together instead of one surface.

Split the derivation into two tiers off the same blend. `border` keeps the 1.5:1
floor for outlines and in-pane rules; `divider` takes 1.2:1 and feeds
`sidebar_border`, which is already used at exactly the six pane seams that want
it — the tab sidebar, the right panel, the document column, and the two
workspace edges in `app.rs`. On the default light theme that moves the seam from
#c8c8c8 to #dfdfdf and leaves every popover outline where it was.

`right_panel`'s rule under the tab row goes back to `border`: same fill above
and below, so the line is carrying the separation alone.

Worth stating because the code hid it: the `mix(bg, fg, 0.16)` seed clears
neither floor in any builtin theme, so both values are decided entirely by the
constants. Lowering the seed changes nothing — that is now in the comment, and
`DIVIDER_FLOOR` is the knob if the light tier turns out too faint.

Claude-Session: https://claude.ai/code/session_01GAjHNse9BDu5jSCjU5QTKe
This commit is contained in:
l0ng-ai
2026-09-04 17:29:17 +08:00
committed by GitHub
parent cd70338486
commit feeb6897ff
3 changed files with 79 additions and 29 deletions
+72 -27
View File
@@ -51,6 +51,7 @@ pub struct Neutrals {
pub background: u32,
pub foreground: u32,
pub border: u32,
pub divider: u32,
pub secondary: u32,
pub muted: u32,
pub muted_foreground: u32,
@@ -153,18 +154,35 @@ impl Theme {
let fg = legible_foreground(bg, self.foreground);
let sidebar = mix(bg, fg, 0.03);
let popover = mix(bg, fg, 0.05);
// One hairline value divides all three neutral fills — it is handed to
// `sidebar_border` too, and popover chrome draws with it. Floor it on
// each of them, not only on the window.
let border = [bg, sidebar, popover]
.into_iter()
.fold(mix(bg, fg, 0.16), |hairline, surface| {
at_least(hairline, fg, surface, BORDER_FLOOR)
});
// Two weights, one derivation. Which one a line gets is decided by
// whether it is the *only* thing separating what it sits between:
//
// - `border` closes an outline around a surface that floats on top of
// other content (menu, tooltip, dialog, card) and rules a header off
// from the rows under it. Nothing else says where the edge is, so it
// has to be visible.
// - `divider` runs between two panes that already carry their own
// fills — the sidebar against the terminal, the right panel against
// the workspace. There the line is the second signal, not the first,
// and painting it at full weight is what makes a workspace read as
// boxes bolted together rather than one surface.
//
// Both are floored on all three neutral fills, not only on the window:
// the same value has to be worth something wherever it is painted.
let hairline = |seed: f32, floor: f32| {
[bg, sidebar, popover]
.into_iter()
.fold(mix(bg, fg, seed), |ink, surface| {
at_least(ink, fg, surface, floor)
})
};
let border = hairline(0.16, BORDER_FLOOR);
let divider = hairline(0.16, DIVIDER_FLOOR);
Neutrals {
background: bg,
foreground: fg,
border,
divider,
secondary: mix(bg, fg, 0.09),
muted: mix(bg, fg, 0.06),
muted_foreground: dim(fg, bg, state::TEXT_RESTING),
@@ -562,6 +580,14 @@ const TEXT_FLOOR: f32 = 4.5;
/// amount in every theme.
const BORDER_FLOOR: f32 = 1.5;
/// The same idea one step down, for a line that is not carrying the separation
/// on its own.
///
/// Worth stating because the seed blend cannot say it: `mix(bg, fg, 0.16)`
/// clears neither floor in any builtin theme, so both values are decided
/// entirely here. Lowering the seed changes nothing; this constant is the knob.
const DIVIDER_FLOOR: f32 = 1.2;
/// Keep an authored blend when it already clears `target` on the surface it is
/// painted on, and walk it back toward `toward` only when it does not.
fn at_least(ink: u32, toward: u32, surface: u32, target: f32) -> u32 {
@@ -1947,30 +1973,49 @@ mod tests {
fn hairlines_are_worth_the_same_in_every_theme() {
for t in builtins() {
let m = t.neutrals();
for (name, surface) in [
("background", m.background),
("sidebar", m.sidebar),
("popover", m.popover),
for (tier, ink, floor) in [
("border", m.border, BORDER_FLOOR),
("divider", m.divider, DIVIDER_FLOOR),
] {
let ratio = contrast(m.border, surface);
assert!(
ratio >= BORDER_FLOOR - 0.01,
"{}: border {:#08x} is only {ratio:.2}:1 on the {name}",
t.id,
m.border
);
// A floor, not a target — a hairline that shouts is worse than
// one that whispers.
assert!(
ratio <= 2.2,
"{}: border {:#08x} is {ratio:.2}:1 on the {name} and reads as a frame",
t.id,
m.border
);
for (name, surface) in [
("background", m.background),
("sidebar", m.sidebar),
("popover", m.popover),
] {
let ratio = contrast(ink, surface);
assert!(
ratio >= floor - 0.01,
"{}: {tier} {ink:#08x} is only {ratio:.2}:1 on the {name}",
t.id
);
// A floor, not a target — a hairline that shouts is worse
// than one that whispers.
assert!(
ratio <= 2.2,
"{}: {tier} {ink:#08x} is {ratio:.2}:1 on the {name} and reads as a frame",
t.id
);
}
}
}
}
/// The two tiers have to stay apart, or the split is decoration. A divider
/// that lands on the same value as the border is the state this replaced.
#[test]
fn a_divider_is_lighter_than_a_border_in_every_theme() {
for t in builtins() {
let m = t.neutrals();
assert!(
contrast(m.divider, m.sidebar) < contrast(m.border, m.sidebar),
"{}: divider {:#08x} is not lighter than border {:#08x}",
t.id,
m.divider,
m.border
);
}
}
#[test]
fn accents_are_conditioned_to_carry_ink() {
for t in builtins() {
+4 -1
View File
@@ -519,8 +519,11 @@ impl Tty7App {
(None, true) => tile_trailing_inset_sm(),
(None, false) => CONTENT_INSET,
}))
// `border`, not `sidebar_border`: this rules the tab row off from
// the content below it, with the same fill on both sides, so the
// line is the only thing saying where one ends.
.when(tabs.is_some(), |this| {
this.border_b_1().border_color(cx.theme().sidebar_border)
this.border_b_1().border_color(cx.theme().border)
})
.child(
h_flex()
+3 -1
View File
@@ -815,7 +815,9 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
// see `workspace_surface_color`.
t.sidebar = sidebar_bg.into();
t.tokens.sidebar = sidebar_bg.into();
t.sidebar_border = rgb(m.border).into();
// The lighter tier: every `sidebar_border` site is a pane meeting another
// pane, and both already carry their own fill. See `Neutrals`.
t.sidebar_border = rgb(m.divider).into();
t.sidebar_foreground = rgb(surfaces.sidebar.text_resting).into();
t.sidebar_accent = sidebar_sel.into();
t.tokens.sidebar_accent = Hsla::from(sidebar_sel).into();