Skip to content

Commit 5e44080

Browse files
mizu-junclaudehappy-otter
authored
refactor(client): migrate the three banners onto the InfoBar stack (UI/UX v3 P6b) (#93)
Nexterm shipped three top-of-screen banners — update notice, offline and server error — as three `Option` fields on `ClientState`, three vertex builders totalling 232 structurally identical lines, and three copies of the stacking arithmetic, with the error banner re-deriving its own offset by testing the other two. They are now three kinds of one surface. The three fields are removed rather than wrapped, so the compiler enumerated every call site: the update poller and the connect loop queue through `push_info_bar`, a successful connect clears the offline slot, and `ServerToClient::Error` replaces the error slot instead of overwriting a field. `bar_rects` from P6a is the only function that computes a bar's `y`, and `stack_order` the only one that decides which bar is on top — a source gate over `ui_verts.rs` fails if a second stacking expression comes back. Three user-visible changes come with the consolidation: - The stack draws below the tab bar instead of over it. Chrome hiding chrome is the worse of the two costs — the tab bar is how the user navigates, and hiding it while an error is up is exactly when they need it (D2). - At most two bars are drawn. The stack overlays terminal rows that are neither reflowed nor scrolled, so it must not grow without bound; the count suffix for the rest needs a new string and lands in P6c (D-cap). - `Enter` opens the release page only while the update bar is on top, so an error above it now takes priority (D4). `Esc` still clears the error bar before the update bar, because it walks the same order the stack is drawn in, and the offline bar is skipped rather than dismissed — its content is "still not connected", and it no longer advertises an `[Esc]` that did nothing. Accessibility is unchanged on purpose: the update bar keeps its `Role::Alert` node and the tree hash now reads the stack (excluding the offline bar's elapsed seconds, as before). Giving every kind a node — which is what finally makes a failed shell launch announceable — is P6c, kept separate so that fix is reviewable without this migration around it. 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 8a51a6f commit 5e44080

12 files changed

Lines changed: 519 additions & 352 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
name table as TOML, so a scheme is reachable from both the moment it exists.
4040

4141
### Changed
42+
- The three top-of-screen banners — update notice, offline, server error — are
43+
now one **InfoBar stack**. They were three state fields, three vertex
44+
builders and three copies of the stacking arithmetic, with the error banner
45+
re-deriving its own offset by testing the other two; they are now three kinds
46+
of one surface, laid out by the single `bar_rects` function. Three visible
47+
changes come with it: the stack draws **below the tab bar** instead of over
48+
it, at most **two bars** are drawn at once (a third is queued but not drawn
49+
until P6c gives it a count suffix), and `Enter` opens the release page only
50+
while the update bar is the top one — an error bar above it now takes
51+
priority. `Esc` still clears the error bar before the update bar, and the
52+
offline bar still clears itself on connect and offers no `[Esc]` hint
53+
(UI/UX v3 P6b).
4254
- The chrome surface ramp now picks its direction with the same predicate the
4355
text correction uses (WCAG relative luminance against the readability
4456
watershed) instead of BT.709 on undecoded sRGB channels, and is capped so no

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,14 @@ gated behind a spike.
708708
buttons and the footer's `` / `` links move too; see the design spec's
709709
§8
710710
- [x] P5 contrast everywhere + high-contrast scheme (P5a–P5d, 2026-08-29)
711-
- [ ] P6 InfoBar + consent reclassification
711+
- [ ] P6 InfoBar consolidation (consent reclassification dropped, spec §4)
712+
- [x] P6a InfoBar model + pure layout — shipped via #92 (2026-08-29)
713+
- [x] P6b migrate the three banners onto the stack — the three `Option`
714+
fields and the three builders are gone; the stack draws below the tab bar,
715+
capped at two bars, and `Enter` acts on the top bar only (2026-08-29)
716+
- [ ] P6c AccessKit nodes ×kind + tree hash + `infobar-more-count` ×8 locales
717+
— the phase's most valuable change: the error bar is still unannounced
718+
- [ ] P6d entrance/exit motion + auto-dismissal for the info severity
712719
- [x] P7 base `notitle` custom title bar — shipped early via #46 (2026-07-30)
713720
- [x] P7 spike: Windows 11 snap layouts — answered in production via #49 (2026-07-31)
714721
- [x] P7 default-on decision — `notitle` default on Windows/Linux via #50 (2026-07-31)

nexterm-client-gpu/CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@ Guidance for working inside the GPU client crate. The repo-wide rules — langua
1717
- `settings_<tab>.rs` — one module per migrated category, each exposing `<tab>_widget_descs` (semantics), `build_<tab>_widgets` (semantics + geometry) and `apply_<tab>_action` (the single state transition the mouse, keyboard and AccessKit paths all call). All nine categories are migrated: Theme, Window, Font, Startup, Blocks, Security, Profiles, Ssh and Keybindings. The three list-shaped tabs (Profiles, Ssh, Keybindings) window their entry list via `layout::list_window`, and reserve index 0 for the entry list itself (which entry is selected lives in `selected_host_index` / `selected_key_index`, because that outlives focus moving to the fields); their delete-confirmation dialogs stay hand-written, because a modal over the panel is not a settings row.
1818
- **Focus** is one field: `SettingsPanel.focused_widget_index` (a `WidgetId.index` for the current category), replacing the seven `<tab>_field_focus` counters. What an index means is defined by that category's descriptor builder — its `row` constants where it has them, otherwise the order descriptors are pushed. Every category change goes through `SettingsPanel::set_category`, which resets the index, the scroll offset and any in-flight field edit; do not assign `panel.category` directly. Keyboard ↑/↓ walk the descriptors via `widgets/navigation.rs` (`focus_next` / `focus_prev`), skipping anything that is not a focus stop — `!enabled`, `Label`, and `Swatch` (a swatch duplicates the cycler row above it, so nine extra stops would buy nothing). A new category therefore gains keyboard navigation by describing its controls, with no navigation code of its own. Ssh and Keybindings are the exception and keep bespoke arrow arms, because index 0 addresses their entry list as a whole rather than a described widget.
1919
- `settings/hover.rs``HoverDwell`, the pointer-dwell timer that gates tooltips (500 ms).
20+
- `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.
2021
- `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.
2122
- `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.
22-
- `update_checker.rs` — Polls the GitHub Releases API five seconds after startup. Disabled by `auto_check_update = false`. Results land in `ClientState.update_banner`; `Esc` dismisses, `Enter` opens the release page.
23+
- `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.
2324
- `platform.rs` — Platform-specific utilities. `apply_backdrop` applies the configured `window.backdrop` material: Windows via `DwmSetWindowAttribute(DWMWA_SYSTEMBACKDROP_TYPE)` (Windows 11 build 22621+; a no-op below that), macOS via `window-vibrancy`, Linux not at all. The value mapping, `dwm_backdrop_value`, is a plain `const fn` compiled on every platform so it is testable without Windows. `open_releases_url` opens the release page in the default browser.
2425
- `renderer/background_pass.rs` — Background image rendering (Sprint 5-7 / Phase 3-1). When `WindowConfig.background_image` is set, the image is loaded at startup and each frame draws clear → background image → cell backgrounds → text. NDC + UV computation for each fit mode (cover/contain/stretch/center/tile) lives in the pure function `compute_background_quad`, with 11 unit tests. Images larger than 4096×4096 are downscaled with Lanczos3. Tile mode falls back to stretch when the tile count exceeds 256 (defensive). Reuses the existing `image_pipeline` (used for Sixel/Kitty) instead of introducing a separate one. Supported formats: PNG / JPEG (whichever features are enabled in the workspace `image` crate).
2526
- `animations/` — UI animation foundation. `mod.rs` holds `AnimationManager` and the spring physics (`SpringState`) for the tab accent and per-pane dim; `easing.rs` the time-based helpers (`ease_out_cubic`, `compute_progress`); `curve.rs` the nine Fluent 2 cubic-bezier curves and eight duration steps, transcribed from `microsoft/fluentui` `packages/tokens` (do not re-derive them by eye); `timed.rs` the `Timed { start, duration_ms, curve }` value type; `surface.rs` the `SurfaceMotion` open/close pair shared by every overlay surface (UI/UX v3 P3b). Springs are for motion interrupted by a new target; `Timed` is for transitions with a known start, end and duration. A zero duration — what `AnimationsConfig::scaled_duration_ms` returns when `enabled = false` or `intensity = "off"` — makes a `Timed` finished on creation, which is the whole reduced-motion path. `ClientState::has_active_animation` is the one place that decides whether the event loop requests another frame; a surface that gains a `Timed` adds a clause there or it will simply never animate.

nexterm-client-gpu/src/accessibility.rs

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use accesskit::{
2929
use crate::host_manager::HostManager;
3030
use crate::macro_picker::MacroPicker;
3131
use crate::palette::CommandPalette;
32+
use crate::renderer::overlay::infobar::InfoBarKind;
3233
use crate::settings_panel::SettingsPanel;
3334
use crate::state::{
3435
AlertEntry, AlertKind, ClientState, CloseWindowDialog, ContextMenu, QuickSelectState,
@@ -849,8 +850,13 @@ pub fn build_tree_from_state(state: &ClientState) -> TreeUpdate {
849850
focus = overlay_focus;
850851
}
851852

852-
// ===== Non-modal: update banner =====
853-
if let Some(version) = &state.update_banner {
853+
// ===== Non-modal: update bar (UI/UX v3 P6) =====
854+
// Still only the update kind: giving every `InfoBarKind` a node — which is
855+
// what finally makes the server error announceable — is P6c.
856+
if let Some(version) = state.info_bars.iter().find_map(|bar| match &bar.kind {
857+
InfoBarKind::UpdateAvailable { version } => Some(version),
858+
_ => None,
859+
}) {
854860
nodes.push(build_update_banner_node(version));
855861
root_children.push(UPDATE_BANNER_ID);
856862
}
@@ -2270,14 +2276,20 @@ pub fn compute_tree_state_hash(state: &ClientState) -> u64 {
22702276
}
22712277
}
22722278

2273-
// === update_banner (non-modal) ===
2274-
state.update_banner.hash(&mut h);
2275-
2276-
// === offline_banner (non-modal, Sprint 5-14 / v1.7.8 — P2-1) ===
2277-
// We only care whether the banner is visible (the elapsed-seconds count
2278-
// updates every frame and would otherwise force a tree rebuild every
2279-
// throttle tick — accessibility consumers do not need that granularity).
2280-
state.offline_banner_since.is_some().hash(&mut h);
2279+
// === InfoBar stack (non-modal, UI/UX v3 P6) ===
2280+
// Slot plus message, so adding, removing or rewording a bar rebuilds the
2281+
// tree. The offline bar contributes only its presence: its elapsed-seconds
2282+
// count updates every frame and would otherwise force a rebuild every
2283+
// throttle tick — accessibility consumers do not need that granularity.
2284+
state.info_bars.len().hash(&mut h);
2285+
for bar in &state.info_bars {
2286+
bar.kind.slot().hash(&mut h);
2287+
match &bar.kind {
2288+
InfoBarKind::UpdateAvailable { version } => version.hash(&mut h),
2289+
InfoBarKind::ServerError { message } => message.hash(&mut h),
2290+
InfoBarKind::Offline { .. } => {}
2291+
}
2292+
}
22812293

22822294
// === SR alerts (Sprint 5-11-5) ===
22832295
// Reflect length + each seq + kind. `kind` becomes hashable via `as u8`.
@@ -2878,7 +2890,12 @@ mod tests {
28782890
fn update_banner_coexists_with_palette() {
28792891
let mut state = ClientState::new(80, 24, 1000);
28802892
state.palette.is_open = true;
2881-
state.update_banner = Some("v1.6.0".to_string());
2893+
state.push_info_bar(
2894+
InfoBarKind::UpdateAvailable {
2895+
version: "v1.6.0".to_string(),
2896+
},
2897+
std::time::Instant::now(),
2898+
);
28822899

28832900
let update = build_tree_from_state(&state);
28842901

@@ -3030,7 +3047,12 @@ mod tests {
30303047
let h_none = compute_tree_state_hash(&state_none);
30313048

30323049
let mut state_banner = ClientState::new(80, 24, 1000);
3033-
state_banner.update_banner = Some("v1.6.0".to_string());
3050+
state_banner.push_info_bar(
3051+
InfoBarKind::UpdateAvailable {
3052+
version: "v1.6.0".to_string(),
3053+
},
3054+
std::time::Instant::now(),
3055+
);
30343056
let h_banner = compute_tree_state_hash(&state_banner);
30353057

30363058
assert_ne!(

nexterm-client-gpu/src/renderer/event_handler/lifecycle.rs

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use crate::animations::duration::GENTLE;
2222
use crate::connection::{Connection, ConnectionExt};
2323
use crate::glyph_atlas::{GlyphAtlas, GlyphKey};
2424
use crate::renderer::WgpuState;
25+
use crate::renderer::overlay::infobar::{InfoBarKind, InfoBarSlot};
2526

2627
impl EventHandler {
2728
/// `ApplicationHandler::new_events` implementation.
@@ -309,8 +310,8 @@ impl EventHandler {
309310
}
310311
self.connect_failure_count = 0;
311312
self.connect_failure_started_at = None;
312-
// P2-1: clear the offline banner if it was visible.
313-
self.app.state.offline_banner_since = None;
313+
// P2-1: clear the offline bar if it was visible.
314+
self.app.state.remove_info_bar(InfoBarSlot::Offline);
314315
true
315316
}
316317
Err(e) => {
@@ -340,13 +341,15 @@ impl EventHandler {
340341
}
341342

342343
// P2-1: once the offline streak has lasted past the threshold,
343-
// raise the visible "Connecting…" banner. The renderer reads
344-
// `offline_banner_since` and formats the elapsed seconds.
345-
if self.app.state.offline_banner_since.is_none()
344+
// raise the visible "Connecting…" bar. The bar carries `since`
345+
// and the renderer formats the elapsed seconds from it.
346+
if !self.app.state.has_info_bar(InfoBarSlot::Offline)
346347
&& let Some(started) = self.connect_failure_started_at
347348
&& started.elapsed() >= Self::OFFLINE_BANNER_THRESHOLD
348349
{
349-
self.app.state.offline_banner_since = Some(started);
350+
self.app
351+
.state
352+
.push_info_bar(InfoBarKind::Offline { since: started }, Instant::now());
350353
if let Some(w) = &self.window {
351354
w.request_redraw();
352355
}
@@ -660,12 +663,14 @@ impl EventHandler {
660663
self.app.state.prefix_pending_until = None;
661664
}
662665

663-
// Poll for notifications from the update checker and show the banner.
666+
// Poll for notifications from the update checker and raise the bar.
664667
if self.update_rx.has_changed().unwrap_or(false)
665-
&& let Some(ver) = self.update_rx.borrow_and_update().clone()
666-
&& self.app.state.update_banner.is_none()
668+
&& let Some(version) = self.update_rx.borrow_and_update().clone()
669+
&& !self.app.state.has_info_bar(InfoBarSlot::Update)
667670
{
668-
self.app.state.update_banner = Some(ver);
671+
self.app
672+
.state
673+
.push_info_bar(InfoBarKind::UpdateAvailable { version }, Instant::now());
669674
had_messages = true;
670675
}
671676

nexterm-client-gpu/src/renderer/input_handler/mod.rs

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ use crate::key_map::{
2424
config_key_matches, config_key_matches_token, format_key_event, physical_to_proto_key,
2525
proto_modifiers, winit_code_to_char,
2626
};
27+
use crate::renderer::overlay::infobar;
2728
use crate::vertex_util::grid_to_text;
2829

2930
use super::EventHandler;
@@ -1069,26 +1070,33 @@ impl EventHandler {
10691070
return true;
10701071
}
10711072

1072-
// When the server-error banner is visible: Esc closes it (Sprint 5-12 Phase 1).
1073-
// Processed before update_banner so the overlapping banner clears first.
1074-
if self.app.state.error_banner.is_some()
1075-
&& let WKeyCode::Escape = code
1076-
{
1077-
self.app.state.error_banner = None;
1078-
return true;
1079-
}
1080-
1081-
// While the update-notification banner is visible: Esc closes it, Enter opens the browser
1082-
if self.app.state.update_banner.is_some() {
1073+
// InfoBar stack (UI/UX v3 P6). `Esc` dismisses the loudest bar that can
1074+
// be dismissed at all — which keeps the previous ordering, where the
1075+
// error banner cleared before the update one, without either handler
1076+
// having to know the other exists. The offline bar is skipped rather
1077+
// than dismissed: it reports a condition that is still true.
1078+
if !self.app.state.info_bars.is_empty() {
1079+
let order = infobar::stack_order(self.app.state.info_bars.make_contiguous());
10831080
match code {
10841081
WKeyCode::Escape => {
1085-
self.app.state.update_banner = None;
1086-
return true;
1082+
if let Some(&index) = order
1083+
.iter()
1084+
.find(|&&index| self.app.state.info_bars[index].kind.is_dismissible())
1085+
{
1086+
self.app.state.info_bars.remove(index);
1087+
return true;
1088+
}
10871089
}
1090+
// D4: only the top bar carries an activation, so `Enter` does
1091+
// nothing while an error sits above the update notice.
10881092
WKeyCode::Enter => {
1089-
crate::platform::open_releases_url();
1090-
self.app.state.update_banner = None;
1091-
return true;
1093+
if let Some(&top) = order.first()
1094+
&& self.app.state.info_bars[top].kind.has_activation()
1095+
{
1096+
crate::platform::open_releases_url();
1097+
self.app.state.info_bars.remove(top);
1098+
return true;
1099+
}
10921100
}
10931101
_ => {}
10941102
}

0 commit comments

Comments
 (0)