mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
+72
-27
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user