Skip to content

Commit 7a3de9e

Browse files
mizu-junclaudehappy-otter
authored
fix(client): the pickers' colours come from the tokens, not from literals (UI/UX v3 P4f) (#99)
P4e recorded this and held it back one PR so a colour change and a type change would not land together. It is the last of G11 in `picker.rs`. The macro picker's purple (a `[0.7, 0.3, 1.0, 1.0]` stripe, a `[0.35, 0.15, 0.50, 1.0]` selected fill and two label greys) and the host manager's green did not answer to the colour scheme at all — nine built-in schemes, one fixed purple. None of them had been through P5 either: a fixed fill plus a fixed label is a fixed contrast ratio, which no scheme can rescue. - Identity stripes: `accent_primary` (macros) and `semantic_success` (SSH). - Titles: `text_on(S2).accent` / `.success` — a hue used *as text* goes through the corrected set, never the flat token. - Selected row fill: `semantic_fill(tokens, hue, 0.85)`, the recipe the dialogs use, which walks the blend back toward the surface until the label `on_surface_text` picks clears 4.5:1. The label is that pair by construction. - Unselected labels: `text_on(SurfaceLevel::S2).secondary` — the panel background is `surface_2`, so that is the ground to name. - The palette's and macro picker's query rows were literal white; they are `text_on(S2).primary` now. The purple is dropped on purpose: there is no purple token, and inventing a hue per overlay is the G11 defect rather than a casualty of fixing it. The host manager keeps a green identity because the scheme has one, so the old comment calling that stripe "intentional branding" survives in substance without its literal. A test fails on any rgba literal returning to the file. `semantic_fill` became `pub(super)` to be callable from the pickers; nothing about it changed. 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 a4812a2 commit 7a3de9e

5 files changed

Lines changed: 105 additions & 26 deletions

File tree

docs/plans/2026-08-29-p4-iconography-and-chrome-typography.md

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -494,15 +494,44 @@ principle". Measuring them first found one thing that was not:
494494

495495
Panel sizes and row pitch stay in cells, so nothing moves.
496496

497-
Observed and **not** fixed: the host manager and macro picker draw their row
498-
fills and every text colour from hard-coded literals (`[0.35, 0.15, 0.50, 1.0]`
499-
and friends) — G11 residue that the token migration missed, and which means
500-
those rows do not answer to the scheme and were never contrast-corrected in P5.
501-
Left alone deliberately: changing hue and type size in one PR would make a
502-
visual regression impossible to attribute. It wants its own change, sized as a
503-
G11/P5 follow-up rather than a typography one.
504-
505-
Still deferred after P4e: the status bar (cell-aligned by design — the Lua
497+
Observed and not fixed *in P4e*: the host manager and macro picker drew their
498+
row fills and every text colour from hard-coded literals — G11 residue that the
499+
token migration missed. Held back one PR so a colour change and a type change
500+
would not land together, then fixed in **P4f** (§8.4).
501+
502+
### 8.4 P4f — the pickers' colours come from the tokens (2026-08-30)
503+
504+
The last of G11 in this file. What the literals cost:
505+
506+
- The macro picker's purple (`[0.7, 0.3, 1.0, 1.0]` stripe, `[0.35, 0.15,
507+
0.50, 1.0]` selected fill, two label greys) and the host manager's green
508+
(`[0.2, 0.8, 0.5, 1.0]` and friends) **did not answer to the colour scheme**.
509+
Nine built-in schemes, one fixed purple.
510+
- None of them had been through P5. A fixed fill plus a fixed label is a fixed
511+
contrast ratio, which no scheme can rescue.
512+
513+
The mapping:
514+
515+
| Was | Now |
516+
|---|---|
517+
| macro stripe / title | `accent_primary` / `text_on(S2).accent` |
518+
| host stripe / title | `semantic_success` / `text_on(S2).success` |
519+
| selected row fill | `semantic_fill(tokens, hue, 0.85)` — the recipe the dialogs use, which walks the blend back toward the surface until its label clears 4.5:1 |
520+
| selected row label | `on_surface_text(fill)` — the pair `semantic_fill` guarantees |
521+
| unselected row label | `text_on(SurfaceLevel::S2).secondary` (the panel background is `surface_2`) |
522+
| palette / macro query row | `text_on(S2).primary`, not a literal white |
523+
524+
**The purple is gone on purpose.** There is no purple token, and inventing a
525+
hue per overlay is the G11 defect rather than a casualty of fixing it; the app
526+
accent is what an overlay's identity stripe is for. The host manager keeps a
527+
green identity because the scheme *has* a green — `semantic_success` — so the
528+
old comment calling that stripe "intentional branding, kept as-is" survives in
529+
substance while losing its literal.
530+
531+
A test asserts no rgba literal returns to `picker.rs`. `semantic_fill` became
532+
`pub(super)` to be callable here; nothing about it changed.
533+
534+
Still deferred after P4f: the status bar (cell-aligned by design — the Lua
506535
status format is column-oriented), tab-bar labels (N-3), the SFTP file-transfer
507536
dialog (its field boxes are cell-derived geometry), the context menu, the
508537
hand-written parts of `ssh_tab.rs` / `keybindings_tab.rs`, and a chrome font

docs/plans/ui-ux-modernization-v3.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,12 @@ gated behind a spike.
722722
mouse-hit-tested, so again no hit-region work. Their hard-coded row
723723
colours are left as G11 residue for a separate change (spec §8.3,
724724
2026-08-29)
725+
- [x] P4f — that G11 residue: the two pickers' stripe, title, selected
726+
fill and every label now come from `DesignTokens` / `semantic_fill`
727+
instead of literals, so the rows follow the scheme and their selected
728+
label clears 4.5:1 by construction. The invented purple is dropped for
729+
`accent_primary`; the SSH green becomes `semantic_success` (spec §8.4,
730+
2026-08-30)
725731
- [x] P5 contrast everywhere + high-contrast scheme (P5a–P5d, 2026-08-29)
726732
- [x] P6 InfoBar consolidation (P6a–P6d, 2026-08-29; consent reclassification
727733
dropped, spec §4). Appearance stays on the on-device backlog below — the

nexterm-client-gpu/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ Guidance for working inside the GPU client crate. The repo-wide rules — langua
1919
- `settings/hover.rs``HoverDwell`, the pointer-dwell timer that gates tooltips (500 ms).
2020
- `renderer/overlay/settings/footer.rs` — the footer's two links, `↗ Open config.toml` and `↺ Reset category` (UI/UX v3 P4c). Their labels, ramp step, measurement and rects live here and nowhere else: `footer_links` is the one call both `settings/mod.rs` (draws it) and `event_handler/settings_panel_hit.rs` (tests against it) make, so the click target cannot drift from the glyphs. The pure `place_links` takes measured widths, in the shape `place_tooltip` established. Adding a footer link means an entry here, not a formula in the hit-test — that mirrored formula is exactly what kept these two on the cell path until P4c. `accessibility.rs` announces them from the same module (P4d): `Role::Button` at `SETTINGS_FOOTER_OPEN_ID` / `SETTINGS_FOOTER_RESET_ID`, labelled with `footer::open_text()` / `reset_text()` — the link text without its decorative glyph — and the reset node is present only while `category_resettable()`. Before P4d neither link was in the tree at all.
2121
- `renderer/overlay/infobar.rs` — the InfoBar stack (UI/UX v3 P6): every non-blocking status message — update notice, offline, server error — as one `InfoBarKind` queued on `ClientState.info_bars`, one slot per kind. `bar_rects` is the only function that computes a bar's `y`, and `stack_order` the only one that decides which bar is on top; a new message type is an enum arm, not a fourth builder, and it must not gain stacking arithmetic of its own. Drawn by `ui_verts::build_info_bar_verts` below the tab bar, capped at two visible bars, with `StackLayout::more_label` reporting the rest on the bottom bar. Accessibility is driven from the same enum (P6c): `accessibility::build_info_bar_nodes` emits one `Role::Alert` per queued bar — including bars past the drawn cap — at the stable id `info_bar_node_id(slot)`, so a new slot cannot be added without a node. Motion is per bar (P6d): each carries its own entrance and exit `Timed`, so one bar can fade out while the one under it is still arriving, and `apply_surface_fade` is therefore called *inside* the builder's loop rather than around it. Dismissal never deletes — `ClientState::dismiss_info_bar` starts the exit, `retire_info_bars` drops the bar once it finishes, and `expire_info_bars` applies the info severity's 20 s deadline (`INFO_BAR_TTL`); both are called from the tick in `lifecycle.rs`. A dismissed bar is out of everything but the renderer at once: it takes no key, holds no slot, and leaves the AccessKit tree immediately.
22-
- `renderer/overlay/picker.rs` — the three list pickers (command palette, host manager, macro picker) plus the SFTP transfer dialog. Rows draw through `draw_picker_run` (UI/UX v3 P4e): one measurement shared by the truncation and the draw, vertically centred in a cell-high row. Two-column rows take their name column from `name_column_width` — measured, not `{:<20}`-padded, because character padding only aligns in a monospace font. The selection marker is its own run so selected and unselected labels start at the same x. Panel sizes and row pitch stay in cells; none of the three pickers is mouse-hit-tested. Their row fills and text colours are still hard-coded literals (G11 residue), so a colour change there is a separate concern from the typography.
22+
- `renderer/overlay/picker.rs` — the three list pickers (command palette, host manager, macro picker) plus the SFTP transfer dialog. Rows draw through `draw_picker_run` (UI/UX v3 P4e): one measurement shared by the truncation and the draw, vertically centred in a cell-high row. Two-column rows take their name column from `name_column_width` — measured, not `{:<20}`-padded, because character padding only aligns in a monospace font. The selection marker is its own run so selected and unselected labels start at the same x. Panel sizes and row pitch stay in cells; none of the three pickers is mouse-hit-tested. Colour comes from the tokens (P4f): the identity stripe from `accent_primary` / `semantic_success`, the selected row fill from `overlay::util::semantic_fill` (which walks the blend back until `on_surface_text` clears 4.5:1 against it), labels from `text_on(SurfaceLevel::S2)` or `on_surface_text(fill)`. A test fails on any rgba literal returning to the file.
2323
- `palette.rs` — Command palette (`Ctrl+Shift+P`). Fuzzy search via `SkimMatcherV2`. Sprint 5-7 / Phase 3-3 covers all 25 actions in `execute_action` (Quit, ClosePane, NewWindow, QuickSelect, SetBroadcastOn/Off, …) and persists usage history at `~/.local/state/nexterm/palette_history.json` (atomic write, mode 0600). The pure `rank_actions` function orders by history when the query is empty (last_used desc → use_count desc) and combines fuzzy score with a `history_bonus` (use_count×10 capped at 100, +100 within 1 day, +50 within 1 week) when a query is present. `record_use` records the selection.
2424
- `host_manager.rs` — SSH host manager UI. `load_history()` / `save_history()` persist connection frequency to `host_history.json`. The `PasswordModal` struct handles the password prompt for `auth_type="password"` hosts.
2525
- `update_checker.rs` — Polls the GitHub Releases API five seconds after startup. Disabled by `auto_check_update = false`. Results are queued onto `ClientState.info_bars` as `InfoBarKind::UpdateAvailable`; `Esc` dismisses, `Enter` opens the release page while it is the top bar.

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

Lines changed: 55 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::state::ClientState;
99
use crate::vertex_util::{add_px_rect, add_run_verts, add_string_verts, measure_run};
1010

1111
use super::super::WgpuState;
12-
use super::util::draw_overlay_panel;
12+
use super::util::{draw_overlay_panel, semantic_fill};
1313
use nexterm_config::SurfaceLevel;
1414

1515
/// The selection marker drawn to the left of a picker row.
@@ -149,7 +149,7 @@ impl WgpuState {
149149
py + cell_h * 0.1,
150150
cell_h,
151151
text_max_w,
152-
[1.0, 1.0, 1.0, 1.0],
152+
tokens.text_on(SurfaceLevel::S2).primary,
153153
sw,
154154
sh,
155155
font,
@@ -415,12 +415,15 @@ impl WgpuState {
415415
bg_idx,
416416
);
417417
// Top accent line — intentional purple branding, kept as-is.
418+
// UI/UX v3 P4f: the stripe used to be a hard-coded purple. There is
419+
// no purple token, and inventing one per overlay is the G11 defect
420+
// itself — the app accent is what an overlay's identity stripe is for.
418421
add_px_rect(
419422
px,
420423
py,
421424
pw,
422425
2.0,
423-
[0.7, 0.3, 1.0, 1.0],
426+
tokens.accent_primary,
424427
sw,
425428
sh,
426429
bg_verts,
@@ -446,7 +449,7 @@ impl WgpuState {
446449
py + cell_h * 0.1,
447450
cell_h,
448451
text_max_w,
449-
[0.8, 0.5, 1.0, 1.0],
452+
tokens.text_on(SurfaceLevel::S2).accent,
450453
sw,
451454
sh,
452455
font,
@@ -464,7 +467,7 @@ impl WgpuState {
464467
py + cell_h * 1.1,
465468
cell_h,
466469
text_max_w,
467-
[1.0, 1.0, 1.0, 1.0],
470+
tokens.text_on(SurfaceLevel::S2).primary,
468471
sw,
469472
sh,
470473
font,
@@ -487,13 +490,18 @@ impl WgpuState {
487490
for (i, mac) in items.iter().enumerate().take(panel_rows as usize - 2) {
488491
let item_py = py + cell_h * (i as f32 + 2.2);
489492
let is_selected = i == mp.selected;
493+
// The selected fill is the accent blended into the panel surface,
494+
// the same recipe the dialogs use — `semantic_fill` walks the
495+
// blend back until its label clears 4.5:1, which the hard-coded
496+
// purple never guaranteed on any scheme.
497+
let selected_fill = semantic_fill(tokens, tokens.accent_primary, 0.85);
490498
if is_selected {
491499
add_px_rect(
492500
px + 2.0,
493501
item_py,
494502
pw - 4.0,
495503
cell_h,
496-
[0.35, 0.15, 0.50, 1.0],
504+
selected_fill,
497505
sw,
498506
sh,
499507
bg_verts,
@@ -506,9 +514,12 @@ impl WgpuState {
506514
&mac.description
507515
};
508516
let fg = if is_selected {
509-
[0.95, 0.8, 1.0, 1.0]
517+
// Chosen against the blended fill, which is not a surface
518+
// token — `semantic_fill` guarantees this pair clears the
519+
// contrast floor by construction.
520+
crate::color_util::on_surface_text(selected_fill)
510521
} else {
511-
[0.70, 0.60, 0.78, 1.0]
522+
tokens.text_on(SurfaceLevel::S2).secondary
512523
};
513524
let style = if is_selected { body_strong } else { body };
514525
if is_selected {
@@ -626,13 +637,15 @@ impl WgpuState {
626637
bg_verts,
627638
bg_idx,
628639
);
629-
// Top accent line — intentional green SSH branding, kept as-is.
640+
// Top accent line — the SSH green identity, now taken from the
641+
// scheme's own success hue rather than a literal, so it follows the
642+
// theme (UI/UX v3 P4f).
630643
add_px_rect(
631644
px,
632645
py,
633646
pw,
634647
2.0,
635-
[0.2, 0.8, 0.5, 1.0],
648+
tokens.semantic_success,
636649
sw,
637650
sh,
638651
bg_verts,
@@ -657,7 +670,7 @@ impl WgpuState {
657670
py + cell_h * 0.1,
658671
cell_h,
659672
text_max_w,
660-
[0.2, 0.9, 0.6, 1.0],
673+
tokens.text_on(SurfaceLevel::S2).success,
661674
sw,
662675
sh,
663676
font,
@@ -675,7 +688,7 @@ impl WgpuState {
675688
py + cell_h * 1.1,
676689
cell_h,
677690
text_max_w,
678-
[1.0, 1.0, 1.0, 1.0],
691+
tokens.text_on(SurfaceLevel::S2).primary,
679692
sw,
680693
sh,
681694
font,
@@ -696,13 +709,14 @@ impl WgpuState {
696709
for (i, host) in items.iter().enumerate().take(panel_rows as usize - 2) {
697710
let item_py = py + cell_h * (i as f32 + 2.2);
698711
let is_selected = i == hm.selected;
712+
let selected_fill = semantic_fill(tokens, tokens.semantic_success, 0.85);
699713
if is_selected {
700714
add_px_rect(
701715
px + 2.0,
702716
item_py,
703717
pw - 4.0,
704718
cell_h,
705-
[0.15, 0.45, 0.30, 1.0],
719+
selected_fill,
706720
sw,
707721
sh,
708722
bg_verts,
@@ -713,9 +727,9 @@ impl WgpuState {
713727
// being two runs rather than one padded string.
714728
let target = format!("{}@{}:{}", host.username, host.host, host.port);
715729
let fg = if is_selected {
716-
[0.9, 1.0, 0.9, 1.0]
730+
crate::color_util::on_surface_text(selected_fill)
717731
} else {
718-
[0.70, 0.75, 0.72, 1.0]
732+
tokens.text_on(SurfaceLevel::S2).secondary
719733
};
720734
let style = if is_selected { body_strong } else { body };
721735
if is_selected {
@@ -820,6 +834,32 @@ mod tests {
820834
assert_eq!(name_column_width(&[50.0], GAP, 40.0, MIN_DETAIL), 0.0);
821835
}
822836

837+
/// G11's last residue in this file: the host manager and macro picker
838+
/// drew their stripe, title, selected fill and every label from literals,
839+
/// so those rows did not answer to the colour scheme and were never
840+
/// contrast-corrected in P5. A literal here is how that comes back.
841+
#[test]
842+
fn no_picker_colour_is_written_as_a_literal() {
843+
let src = include_str!("picker.rs");
844+
let production = src
845+
.split("#[cfg(test)]")
846+
.next()
847+
.expect("the file has a production half");
848+
for (n, line) in production.lines().enumerate() {
849+
let code = line.trim_start();
850+
if code.starts_with("//") {
851+
continue;
852+
}
853+
// An rgba literal in this file is always four floats in brackets.
854+
assert!(
855+
!(code.contains("[0.") || code.contains("[1.0")),
856+
"picker.rs:{} writes a colour literal again; \
857+
DesignTokens and semantic_fill own the palette: {code}",
858+
n + 1
859+
);
860+
}
861+
}
862+
823863
/// The three pickers must not go back to aligning columns by character
824864
/// count: a left-pad format spec lines up only in a monospace font, which
825865
/// is exactly the assumption the ramp removes.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,11 @@ pub(super) fn caution_fill(tokens: &nexterm_config::DesignTokens, strength: f32)
237237
/// The consequence is that some schemes get a slightly quieter fill than the
238238
/// caller asked for. That is the intended trade — an unreadable label on a
239239
/// destructive button is worse than a less saturated one.
240-
fn semantic_fill(tokens: &nexterm_config::DesignTokens, hue: [f32; 4], strength: f32) -> [f32; 4] {
240+
pub(super) fn semantic_fill(
241+
tokens: &nexterm_config::DesignTokens,
242+
hue: [f32; 4],
243+
strength: f32,
244+
) -> [f32; 4] {
241245
let base = [
242246
tokens.surface_1[0],
243247
tokens.surface_1[1],

0 commit comments

Comments
 (0)