Skip to content

Commit fe2db95

Browse files
mizu-junclaudehappy-otter
authored
refactor(client): put the modal dialog buttons on tokens, readably (UI/UX v3 G11 follow-up) (#71)
Third and last code half of the #62 follow-up: the close-window dialog's Kill/Cancel buttons, and the consent dialog's selected-button fill. The literals were `[0.75, 0.25, 0.25]` / `[0.95, 0.40, 0.40]` for Kill at rest and selected, `[0.95, 0.85, 0.40]` for a selected Cancel, and `[0.10, 0.10, 0.10]` for the label on any selected button (in two dialogs). All now derive from `semantic_error` / `semantic_warning` via two new helpers, `util::danger_fill` and `util::caution_fill`. Kill deliberately does *not* reuse #70's `danger_button_colors`. That helper answers "is this button focused", stepping from a barely-tinted rest state to a mid blend. Kill has to read as the destructive choice *before* it is selected — it sits next to Cancel — so it steps from the mid blend to a strong one instead. Sharing the blend and not the focused/unfocused rule is what keeps both readings intact. The labels are the substance of this change. Selected-button labels were a fixed near-black, which is wrong on any dark fill; they now come from `on_surface_text`, chosen against the fill. Measuring that across the nine built-in schemes turned up something a fixed blend strength cannot fix: at 0.85 the error hue lands at a middling luminance on Nord (4.42:1) and the warning hue does the same on Solarized (4.37:1) — luminances where *neither* extreme has anything to contrast with. Used raw, `semantic_warning` fails the same way, which is why `caution_fill` blends rather than passing the token through. `semantic_fill` therefore walks the blend back toward `surface_1` until the label clears `MIN_TEXT_CONTRAST`, the same shape as `row::ensure_readable`. It terminates because `surface_1` derives from the scheme background — the end of the range a label can always be read against. Some schemes get a slightly quieter fill than asked for; an unreadable label on a destructive button is the worse trade. The consent dialog's 3 px top stripe and its title keep the raw warning hue: a line and text, not a fill. Two new tests. One pins every fill/label pair the dialogs paint across all nine schemes at 4.5:1 — it is the test that forced `caution_fill` to exist, and it fails on `Nord` if the adaptive step is removed. The other pins that a stronger blend always reads as redder, since that ordering is what carries "dangerous" and "selected". On-device verification: not run. Joins the accepted-unverified backlog. Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent 7e71284 commit fe2db95

3 files changed

Lines changed: 181 additions & 26 deletions

File tree

nexterm-client-gpu/src/renderer/overlay/dialog.rs

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ use crate::vertex_util::{add_px_rect, add_string_verts, visual_width};
99

1010
use super::super::WgpuState;
1111
use super::util::{
12-
SCRIM_ALPHA_FLOOR, draw_overlay_panel, pane_id_for, preview_text, scrim_color, wrap_text,
12+
SCRIM_ALPHA_FLOOR, caution_fill, danger_fill, draw_overlay_panel, pane_id_for, preview_text,
13+
scrim_color, wrap_text,
1314
};
1415

1516
impl WgpuState {
@@ -453,14 +454,24 @@ impl WgpuState {
453454
for (i, btn) in buttons.iter().enumerate() {
454455
let bw = visual_width(btn) as f32 * cell_w + cell_w * 1.5;
455456
let is_selected = dialog.selected == i;
457+
// UI/UX v3 (G11): the selected fill is the warning hue blended into
458+
// the panel surface, not the raw token. Used raw it sits at a
459+
// middling luminance on some schemes (Solarized: neither a dark nor
460+
// a light label clears 4.5:1 against it); blending gives the label
461+
// an extreme to contrast against. The 3 px stripe and the title
462+
// above keep the raw hue — they are a line and text, not a fill.
456463
let bg = if is_selected {
457-
warn_color
464+
caution_fill(tokens, 0.85)
458465
} else {
459466
tokens.surface_3
460467
};
461468
add_px_rect(bx, btn_y, bw, cell_h * 1.4, bg, sw, sh, bg_verts, bg_idx);
469+
// UI/UX v3 (G11): the selected button is filled with the warning
470+
// hue, so its label is chosen against *that* fill. `text_on_accent`
471+
// would answer for `accent_primary` and could put a light label on
472+
// a pale yellow button.
462473
let fg = if is_selected {
463-
[0.10, 0.10, 0.10, 1.0]
474+
crate::color_util::on_surface_text(bg)
464475
} else {
465476
tokens.text_primary
466477
};
@@ -590,8 +601,16 @@ impl WgpuState {
590601
}
591602

592603
// Button row: Kill (left, selected_button == 0) + Cancel (right, selected_button == 1)
604+
//
605+
// UI/UX v3 (G11): both buttons' fills now come from tokens. Kill keeps
606+
// its "red even when not selected" reading — it has to be identifiable
607+
// as the destructive choice before the user moves onto it — so it steps
608+
// between two `danger_fill` strengths rather than reusing the settings
609+
// dialogs' focused/unfocused pair. Cancel keeps its warm selected fill
610+
// (the deliberate "you are on the safe side" signal that the consent
611+
// dialog already spells with `semantic_warning`).
593612
let buttons: [(&str, [f32; 4]); 2] = [
594-
(&dialog.kill_label, [0.75, 0.25, 0.25, 1.0]),
613+
(&dialog.kill_label, danger_fill(tokens, 0.55)),
595614
(&dialog.cancel_label, tokens.surface_3),
596615
];
597616
let btn_y = py + ph - cell_h * 2.6;
@@ -606,17 +625,20 @@ impl WgpuState {
606625
// Selected: fill with the accent color; unselected: base color
607626
let bg = if is_selected {
608627
if i == 0 {
609-
[0.95, 0.40, 0.40, 1.0] // Kill selected: vivid red
628+
danger_fill(tokens, 0.85) // Kill selected: strongest red
610629
} else {
611-
[0.95, 0.85, 0.40, 1.0] // Cancel selected: yellow (safe side)
630+
caution_fill(tokens, 0.85) // Cancel selected: warm (safe side)
612631
}
613632
} else {
614633
*base_bg
615634
};
616635
let bw = btn_widths[i];
617636
add_px_rect(bx, btn_y, bw, cell_h * 1.4, bg, sw, sh, bg_verts, bg_idx);
618-
let fg = if is_selected {
619-
[0.10, 0.10, 0.10, 1.0]
637+
// A semantic fill (either Kill state, or a selected Cancel) needs
638+
// its label chosen against that fill; a plain surface fill reads
639+
// best in the scheme's own foreground.
640+
let fg = if is_selected || i == 0 {
641+
crate::color_util::on_surface_text(bg)
620642
} else {
621643
tokens.text_primary
622644
};

nexterm-client-gpu/src/renderer/overlay/settings/row.rs

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ use crate::font::FontManager;
2626
use crate::glyph_atlas::{GlyphAtlas, TextVertex};
2727
use crate::vertex_util::{add_string_verts, truncate_to_width};
2828

29-
use super::super::util::wrap_text;
29+
use super::super::util::{danger_fill, wrap_text};
3030

3131
/// WCAG 2.x contrast floor used throughout the settings panel (project
3232
/// accessibility guideline: see `CLAUDE.md` "UI/UX Guidelines").
@@ -63,9 +63,9 @@ pub(in crate::renderer) fn ensure_readable(
6363
/// different resting treatments (a dark red against `surface_1`). Both now
6464
/// derive from `semantic_error`.
6565
///
66-
/// The error hue is blended *into* the panel surface rather than used raw: a
67-
/// saturated ANSI red leaves no headroom for a readable label, which is
68-
/// precisely why both call sites had darkened it by hand.
66+
/// The fills come from [`danger_fill`], which is shared with the close-window
67+
/// dialog: this is the focused/unfocused pair a *settings* delete button
68+
/// wants, where red means "focused" rather than "dangerous".
6969
///
7070
/// The two states take their label from different places, and the reason is
7171
/// measurable rather than aesthetic. The focused fill is red enough that the
@@ -78,23 +78,11 @@ pub(in crate::renderer) fn danger_button_colors(
7878
tokens: &nexterm_config::DesignTokens,
7979
focused: bool,
8080
) -> ([f32; 4], [f32; 4]) {
81-
let tint = |amount: f32| -> [f32; 4] {
82-
let base = [
83-
tokens.surface_1[0],
84-
tokens.surface_1[1],
85-
tokens.surface_1[2],
86-
];
87-
let rgb = crate::color_util::composite_over(
88-
crate::color_util::with_alpha(tokens.semantic_error, amount),
89-
base,
90-
);
91-
[rgb[0], rgb[1], rgb[2], 1.0]
92-
};
9381
if focused {
94-
let bg = tint(0.55);
82+
let bg = danger_fill(tokens, 0.55);
9583
(bg, crate::color_util::on_surface_text(bg))
9684
} else {
97-
let bg = tint(0.18);
85+
let bg = danger_fill(tokens, 0.18);
9886
(
9987
bg,
10088
ensure_readable(tokens.text_primary, bg, MIN_TEXT_CONTRAST),

nexterm-client-gpu/src/renderer/overlay/util.rs

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
//! Shared helpers used by overlay rendering.
22
3+
use super::settings::row::MIN_TEXT_CONTRAST;
4+
35
/// Extract the requesting pane ID from a consent-dialog kind
46
pub(super) fn pane_id_for(kind: &crate::state::ConsentKind) -> Option<u32> {
57
use crate::state::ConsentKind;
@@ -172,6 +174,78 @@ pub(super) fn scrim_color(tokens: &nexterm_config::DesignTokens, alpha: f32) ->
172174
crate::color_util::with_alpha(tokens.surface_0, alpha)
173175
}
174176

177+
/// Opaque fill for a destructive action: `semantic_error` blended into the
178+
/// panel's own `surface_1` at `strength`.
179+
///
180+
/// The raw ANSI red is deliberately never used as a fill — it leaves no
181+
/// headroom for a readable label, which is why every call site had been
182+
/// darkening it by hand before UI/UX v3 (G11 follow-up).
183+
///
184+
/// `strength` is what separates the two questions a destructive button
185+
/// answers. The settings delete dialogs only turn red once focused, so they
186+
/// step from a barely-tinted rest state to a mid blend. The close-window
187+
/// dialog's Kill button is red at all times — it sits next to Cancel and must
188+
/// read as the dangerous one before it is ever selected — so it steps from
189+
/// that same mid blend to a strong one. Callers pass the pair that carries
190+
/// their own semantics rather than sharing one focused/unfocused rule.
191+
pub(super) fn danger_fill(tokens: &nexterm_config::DesignTokens, strength: f32) -> [f32; 4] {
192+
semantic_fill(tokens, tokens.semantic_error, strength)
193+
}
194+
195+
/// Opaque fill for the *safe* side of a destructive choice, and for a
196+
/// selected button in the consent dialog: `semantic_warning` blended into
197+
/// `surface_1` the same way [`danger_fill`] blends the error hue.
198+
///
199+
/// Blending matters here for a measurable reason, not for symmetry. Used raw,
200+
/// `semantic_warning` sits at a middling luminance on some schemes — on
201+
/// Solarized neither a near-black nor a near-white label clears 4.5:1 against
202+
/// it (the best either extreme manages is 4.37:1). Blending the hue into the
203+
/// panel surface pulls the fill towards that surface's own end of the range,
204+
/// which gives the label an extreme to contrast against again.
205+
pub(super) fn caution_fill(tokens: &nexterm_config::DesignTokens, strength: f32) -> [f32; 4] {
206+
semantic_fill(tokens, tokens.semantic_warning, strength)
207+
}
208+
209+
/// Blend a semantic hue into the panel's `surface_1`, walking the blend back
210+
/// toward the surface until the label [`crate::color_util::on_surface_text`]
211+
/// would pick clears [`MIN_TEXT_CONTRAST`] against it.
212+
///
213+
/// A fixed strength cannot serve all nine built-in schemes. At 0.85 the error
214+
/// hue lands at a middling luminance on Nord (4.42:1) and the warning hue does
215+
/// the same on Solarized (4.37:1) — luminances where neither a near-black nor
216+
/// a near-white label has anything to contrast with. Stepping back toward the
217+
/// panel surface always terminates: `surface_1` is derived from the scheme's
218+
/// background, which is the end of the range a label can always be read
219+
/// against.
220+
///
221+
/// The consequence is that some schemes get a slightly quieter fill than the
222+
/// caller asked for. That is the intended trade — an unreadable label on a
223+
/// destructive button is worse than a less saturated one.
224+
fn semantic_fill(tokens: &nexterm_config::DesignTokens, hue: [f32; 4], strength: f32) -> [f32; 4] {
225+
let base = [
226+
tokens.surface_1[0],
227+
tokens.surface_1[1],
228+
tokens.surface_1[2],
229+
];
230+
let blend = |s: f32| -> [f32; 4] {
231+
let rgb = crate::color_util::composite_over(crate::color_util::with_alpha(hue, s), base);
232+
[rgb[0], rgb[1], rgb[2], 1.0]
233+
};
234+
let mut s = strength;
235+
loop {
236+
let fill = blend(s);
237+
let label = crate::color_util::on_surface_text(fill);
238+
let cr = crate::color_util::contrast_ratio(
239+
[label[0], label[1], label[2]],
240+
[fill[0], fill[1], fill[2]],
241+
);
242+
if cr >= MIN_TEXT_CONTRAST || s <= 0.05 {
243+
return fill;
244+
}
245+
s -= 0.05;
246+
}
247+
}
248+
175249
#[cfg(test)]
176250
mod tests {
177251
use super::*;
@@ -225,6 +299,77 @@ mod tests {
225299
assert!((scrim[3] - SCRIM_ALPHA_FLOOR).abs() < 1e-6);
226300
}
227301

302+
/// Every fill the modal dialogs paint a label onto must leave that label
303+
/// above the project's 4.5:1 floor, on every built-in scheme.
304+
///
305+
/// This is the test that forced `caution_fill` to exist: used raw,
306+
/// `semantic_warning` tops out at 4.37:1 on Solarized because neither
307+
/// extreme contrasts with a middling luminance. Measured after blending —
308+
/// worst cases: 4.63:1 (Gruvbox, Kill selected), 4.83:1 (Dark, Kill
309+
/// selected), 4.87:1 (Solarized, Cancel selected).
310+
#[test]
311+
fn dialog_button_labels_clear_the_contrast_floor() {
312+
// Mirrors `row::MIN_TEXT_CONTRAST`, which is scoped to the settings
313+
// panel's module tree.
314+
const FLOOR: f32 = 4.5;
315+
for scheme in [
316+
nexterm_config::BuiltinScheme::Dark,
317+
nexterm_config::BuiltinScheme::Light,
318+
nexterm_config::BuiltinScheme::Gruvbox,
319+
nexterm_config::BuiltinScheme::Solarized,
320+
nexterm_config::BuiltinScheme::Catppuccin,
321+
nexterm_config::BuiltinScheme::Dracula,
322+
nexterm_config::BuiltinScheme::Nord,
323+
nexterm_config::BuiltinScheme::OneDark,
324+
nexterm_config::BuiltinScheme::TokyoNight,
325+
] {
326+
let tokens = tokens_for(scheme);
327+
let fills = [
328+
("kill resting", danger_fill(&tokens, 0.55)),
329+
("kill selected", danger_fill(&tokens, 0.85)),
330+
("caution selected", caution_fill(&tokens, 0.85)),
331+
];
332+
for (name, bg) in fills {
333+
let fg = crate::color_util::on_surface_text(bg);
334+
let cr =
335+
crate::color_util::contrast_ratio([fg[0], fg[1], fg[2]], [bg[0], bg[1], bg[2]]);
336+
assert!(
337+
cr >= FLOOR,
338+
"{scheme:?} {name}: label only reached {cr} against {bg:?}"
339+
);
340+
}
341+
}
342+
}
343+
344+
/// UI/UX v3 (G11 follow-up): every destructive fill across the dialogs is
345+
/// one hue at a different strength, so a stronger blend must always read
346+
/// as redder. That ordering is what carries both "this is the dangerous
347+
/// button" and "and it is the one currently selected"; if it inverted on
348+
/// some scheme, the close-window dialog would highlight Kill by making it
349+
/// *less* red.
350+
#[test]
351+
fn danger_fill_gets_redder_with_strength() {
352+
let redness = |c: [f32; 4]| c[0] - (c[1] + c[2]) / 2.0;
353+
for scheme in [
354+
nexterm_config::BuiltinScheme::Dark,
355+
nexterm_config::BuiltinScheme::Light,
356+
nexterm_config::BuiltinScheme::Gruvbox,
357+
nexterm_config::BuiltinScheme::Solarized,
358+
] {
359+
let tokens = tokens_for(scheme);
360+
let weak = danger_fill(&tokens, 0.18);
361+
let mid = danger_fill(&tokens, 0.55);
362+
let strong = danger_fill(&tokens, 0.85);
363+
assert!(
364+
redness(strong) > redness(mid) && redness(mid) > redness(weak),
365+
"{scheme:?}: strength does not order by redness ({:?} / {:?} / {:?})",
366+
redness(weak),
367+
redness(mid),
368+
redness(strong)
369+
);
370+
}
371+
}
372+
228373
/// Regression guard against a hard-coded black coming back: a light scheme
229374
/// and a dark one must veil in different colours, the way the settings
230375
/// panel's scrim already did before the other call sites were migrated.

0 commit comments

Comments
 (0)