fix(theme): finish the sweep — plain-button states and midtone grounds

Two gaps in the interaction-state change.

`Theme::input` reaches the *outline* button path, which reads the field
live, but not the plain one: gpui-component derives `tokens.button_hover`
and `tokens.button_active` from `input` once, inside the `apply_config`
that `Theme::change` runs — from the stock `#2f2f2f`, before any of
`apply_theme`'s overrides exist. A snapshot never sees the fix, so a plain
`Button` still hovered and pressed in that grey and `Button::selected`
(the terminal search bar's `Aa` / `.*` toggles, the last two left in the
app) filled from `tokens.secondary_active` the same way — ~1.03:1 on
Dracula, i.e. issue #197 again, one snapshot removed from the field that
fixed it. Point those state rungs at the window ladder. `tokens.button`,
the resting fill, is deliberately left alone.

`legible_ink` picked its extreme with `is_dark`, whose 0.5 luminance
threshold is the wrong question: on a midtone ground (luminance
0.18…0.5) it says "dark" while black outreaches white, so a status seed
was driven toward white and clamped there *below* the floor with its hue
gone — precisely when a user most needs to tell an error from a warning.
Take the reachable extreme instead, as `legible_foreground` already does.
Every built-in sits far enough from the midpoint that this picks what
`is_dark` did, so the derived palettes are unchanged; only an imported
scheme could reach the divergent range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD
This commit is contained in:
thomas
2026-07-27 09:39:26 +08:00
co-authored by Claude Opus 5
parent dcb8ba0c77
commit 4107829bf8
2 changed files with 56 additions and 1 deletions
+34 -1
View File
@@ -591,7 +591,19 @@ fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 {
if contrast(seed, bg) >= floor {
return seed;
}
let away = if is_dark(bg) { 0xffffff } else { 0x000000 };
// Whichever extreme the background is *further* from, exactly as
// [`legible_foreground`] picks it — not `is_dark`, whose 0.5 luminance
// threshold is the wrong question here. The two answers only diverge on a
// midtone background (luminance 0.18…0.5), where `is_dark` still says "dark"
// but black outreaches white: an imported scheme on a mid-grey ground would
// have been driven to pure white and clamped there *below* the floor, losing
// the hue and failing the job in one go. Every built-in is far enough from
// the midpoint that this picks what `is_dark` did.
let away = if contrast(0xffffff, bg) >= contrast(0x000000, bg) {
0xffffff
} else {
0x000000
};
bisect_contrast(seed, away, bg, floor)
}
@@ -1666,6 +1678,27 @@ mod tests {
assert_eq!(raise(0x000000, 0x808080, 21.0), 0x808080);
}
/// Conditioning has to take the extreme it can actually *reach*, which on a
/// midtone ground is not the one `is_dark`'s 0.5 luminance threshold names.
/// A mid-grey background is "dark" by that test, yet white tops out at
/// 3.95:1 on it while black manages 5.32:1 — so driving toward white would
/// clamp at pure white, below the floor and with the hue thrown away, in the
/// one case where a status colour most needs both. Reachable only for an
/// imported scheme; every built-in sits far enough from the midpoint that
/// this picks the same extreme `is_dark` did.
#[test]
fn semantic_conditioning_survives_a_midtone_background() {
let bg = 0x808080;
for seed in [0xff5555u32, 0x50fa7b, 0xf1fa8c, 0x8be9fd] {
let ink = legible_ink(bg, seed, TEXT_FLOOR);
assert!(
contrast(ink, bg) >= TEXT_FLOOR - 0.01,
"{seed:#08x} conditioned to {ink:#08x}, only {:.2}:1 on a midtone ground",
contrast(ink, bg)
);
}
}
/// A bad foreground is swapped for a legible black/white; a good one is kept.
#[test]
fn legible_foreground_rescues_unreadable_text() {
+22
View File
@@ -709,6 +709,28 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
t.input = rgb(surfaces.window.selected).into();
t.tokens.input = Hsla::from(rgb(surfaces.window.selected)).into();
// …but `input` only reaches the *outline* path, which reads the field live.
// The plain (non-outline) button family is derived from `input` **once**,
// inside the `apply_config` that `Theme::change` ran above — i.e. from the
// stock `#2f2f2f`, before any of this function's overrides exist — and a
// snapshot never sees the fix. So a plain `Button` still hovered and pressed
// in that grey, and `Button::selected` (the terminal search bar's `Aa` / `.*`
// toggles, the last two in the app) filled from `tokens.secondary_active` the
// same way: on Dracula, ~1.03:1 against the surface behind it. That is issue
// #197 again, one snapshot removed from the field that fixed it.
//
// Only the *state* rungs move — `tokens.button` (the resting fill) is left
// alone, so a plain button keeps the flat look it has today and only its
// hover/pressed/selected join the ladder.
let button_hover: Hsla = rgb(surfaces.window.hover).into();
let button_active: Hsla = rgb(surfaces.window.selected).into();
t.tokens.button_hover = button_hover.into();
t.tokens.button_active = button_active.into();
t.tokens.secondary_hover = button_hover.into();
t.tokens.secondary_active = button_active.into();
t.tokens.button_secondary_hover = button_hover.into();
t.tokens.button_secondary_active = button_active.into();
// Focus rings: the one place the theme's *real* accent belongs. A ring is ink
// on the background at 12px, which is exactly the job `legible_accent`
// conditions the seed for; the stock `neutral-300` was both off-theme and