From c5aaf2b4f72346bdcdca14282ed3bbfaaf8e9e81 Mon Sep 17 00:00:00 2001 From: l0ng-ai Date: Sat, 8 Aug 2026 09:11:01 +0800 Subject: [PATCH] fix(theme): floor the tokens that bypassed the contrast machinery (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(theme): floor the tokens that bypassed the contrast machinery Sidebar text, the caret and hairlines were flat blends with no floor, sitting one line away from tokens that are bisected to hit a target exactly. Semantic inks were floored against the window background but painted on popovers and sidebar rows, which sit a step toward the foreground. - sidebar_fg is now floored at 4.5:1 on the sidebar fill it is painted on, not on the background. Four builtins landed it at 3.35-3.92:1. - caret is conditioned to 3:1. The default Light theme shipped an orange caret on pure white at 2.07:1. - border keeps its blend but gets a 1.5:1 floor so a divider is worth the same in every theme, instead of ranging 1.26-1.64:1. - semantic inks and fills clear their floor on background, sidebar and popover, not just the background. Themes that already cleared a floor are untouched. * fix(theme): floor the hairline on every neutral fill it divides Review catch on #400: border is handed to sidebar_border and drawn on popover chrome, but was floored against the window background alone — a narrower guarantee than the one this PR gives sidebar_fg and the semantic inks. --------- Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> --- src/ui/presets.rs | 170 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 22 deletions(-) diff --git a/src/ui/presets.rs b/src/ui/presets.rs index be0d9787..577dc434 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -131,18 +131,32 @@ impl Theme { pub fn neutrals(&self) -> Neutrals { let bg = self.background_color(); 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) + }); Neutrals { background: bg, foreground: fg, - border: mix(bg, fg, 0.16), + border, secondary: mix(bg, fg, 0.09), muted: mix(bg, fg, 0.06), muted_foreground: dim(fg, bg, state::TEXT_RESTING), - popover: mix(bg, fg, 0.05), - caret: self.caret.unwrap_or(self.accent), + popover, + caret: legible_ink(bg, self.caret.unwrap_or(self.accent), ACCENT_FLOOR), selection: self.selection.unwrap_or_else(|| mix(bg, fg, 0.20)), - sidebar: mix(bg, fg, 0.03), - sidebar_fg: mix(fg, bg, 0.28), + 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 nine 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), accent: legible_accent(bg, self.accent), } } @@ -170,10 +184,20 @@ impl Theme { let (r, g, b) = self.ansi16[i]; (r as u32) << 16 | (g as u32) << 8 | b as u32 }; + // An error line lands on a popover or a sidebar row as often as on the + // window, and both of those fills sit a step toward the foreground. + // Clear the floor on every surface the ink can be painted on, not just + // on the darkest one. + let surfaces = [bg, mix(bg, fg, 0.03), mix(bg, fg, 0.05)]; + let clear = |seed: u32, floor: f32| { + surfaces + .iter() + .fold(seed, |ink, surface| legible_ink(*surface, ink, floor)) + }; let build = |seed: u32| { - let fill = legible_ink(bg, seed, ACCENT_FLOOR); + let fill = clear(seed, ACCENT_FLOOR); Semantic { - ink: legible_ink(bg, seed, TEXT_FLOOR), + ink: clear(seed, TEXT_FLOOR), fill, on_fill: ink_on(fill, fg, TEXT_FLOOR), } @@ -361,6 +385,26 @@ const ACCENT_FLOOR: f32 = 3.0; const TEXT_FLOOR: f32 = 4.5; +/// 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 +/// where the flat blend disappears entirely, so a divider is worth the same +/// amount in every theme. +const BORDER_FLOOR: f32 = 1.5; + +/// 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 { + if contrast(ink, surface) >= target { + return ink; + } + let lifted = bisect_contrast(ink, toward, surface, target); + if contrast(lifted, surface) >= target { + return lifted; + } + legible_ink(surface, lifted, target) +} + fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 { if contrast(seed, bg) >= floor { return seed; @@ -1259,6 +1303,7 @@ mod tests { fn semantic_colors_clear_their_floors() { for t in builtins() { let bg = t.background_color(); + let m = t.neutrals(); let s = t.semantics(); for (name, c) in [ ("danger", s.danger), @@ -1267,20 +1312,28 @@ mod tests { ("info", s.info), ("link", s.link), ] { - assert!( - contrast(c.ink, bg) >= TEXT_FLOOR - 0.01, - "{}/{name}: ink {:#08x} only {:.2}:1 on the background", - t.id, - c.ink, - contrast(c.ink, bg) - ); - assert!( - contrast(c.fill, bg) >= ACCENT_FLOOR - 0.01, - "{}/{name}: fill {:#08x} only {:.2}:1 on the background", - t.id, - c.fill, - contrast(c.fill, bg) - ); + // Every neutral fill the ink can land on, not just the window: + // a failure notice is usually read inside a popover. + for (surface, fill) in [ + ("background", bg), + ("sidebar", m.sidebar), + ("popover", m.popover), + ] { + assert!( + contrast(c.ink, fill) >= TEXT_FLOOR - 0.01, + "{}/{name}: ink {:#08x} only {:.2}:1 on the {surface}", + t.id, + c.ink, + contrast(c.ink, fill) + ); + assert!( + contrast(c.fill, fill) >= ACCENT_FLOOR - 0.01, + "{}/{name}: fill {:#08x} only {:.2}:1 on the {surface}", + t.id, + c.fill, + contrast(c.fill, fill) + ); + } assert!( contrast(c.on_fill, c.fill) >= TEXT_FLOOR - 0.01, "{}/{name}: text on its own fill is only {:.2}:1", @@ -1310,7 +1363,80 @@ mod tests { (r as u32) << 16 | (g as u32) << 8 | b as u32 }; assert_eq!(ansi_red, 0xff5555, "Dracula's ANSI red moved"); - assert_eq!(dracula.semantics().danger.ink, ansi_red); + // Conditioning may lift the seed to clear its floor on a popover, but + // the result has to stay recognisably the palette's own red rather than + // some house error colour. + let ink = dracula.semantics().danger.ink; + assert!( + channel_distance(ink, ansi_red) <= 32, + "danger ink {ink:#08x} drifted off Dracula's ANSI red {ansi_red:#08x}" + ); + let (r, g, b) = (ink >> 16 & 0xff, ink >> 8 & 0xff, ink & 0xff); + assert!( + r > g && r > b, + "danger ink {ink:#08x} is no longer red-dominant" + ); + } + + #[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, + "{}: sidebar text {:#08x} is only {ratio:.2}:1 on the sidebar fill {:#08x}", + t.id, + m.sidebar_fg, + m.sidebar + ); + } + } + + #[test] + fn carets_stay_visible_on_the_background() { + for t in builtins() { + let m = t.neutrals(); + let ratio = contrast(m.caret, m.background); + assert!( + ratio >= ACCENT_FLOOR - 0.01, + "{}: caret {:#08x} is only {ratio:.2}:1 on the background", + t.id, + m.caret + ); + } + // The default theme is the one that used to fail: an orange caret on + // pure white read at 2.07:1. + let light = builtins().into_iter().find(|t| t.id == DEFAULT_ID).unwrap(); + assert_ne!(light.neutrals().caret, light.caret.unwrap()); + } + + #[test] + 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), + ] { + 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 + ); + } + } } #[test]