|
| 1 | +# P6 — Notification surfaces (design spec) |
| 2 | + |
| 3 | +Status: **approved 2026-08-29** — §4 signed off by the maintainer; ready to implement |
| 4 | +Date: 2026-08-29 |
| 5 | +Parent plan: [`ui-ux-modernization-v3.md`](./ui-ux-modernization-v3.md) § P6 |
| 6 | +Addresses: principles Calm + Personal |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## 0. Why this spec exists |
| 11 | + |
| 12 | +The parent roadmap describes P6 as three bullets. Measuring first — as the P4 |
| 13 | +and P5 specs did — shows the first is built on a wrong premise, the second is a |
| 14 | +security change that should not be made, and only the third survives intact. |
| 15 | +Both corrections are settled: the scope below is what P6 implements. |
| 16 | + |
| 17 | +1. **"New `overlay/infobar.rs` … reuses the update-banner slot pattern in |
| 18 | + `ClientState`."** There is no *the* slot to reuse. Nexterm already ships |
| 19 | + **three** top-of-screen banners, each with its own state field, its own |
| 20 | + builder, and its own hand-written stacking arithmetic. P6 is not "add a |
| 21 | + fourth"; it is "there are already three, and they disagree with each other". |
| 22 | + |
| 23 | +2. **"Reclassify `ConsentDialog` kinds … move low-risk notices to InfoBar."** |
| 24 | + All three consent kinds are *pre-action authorisations* — the action does not |
| 25 | + happen unless the user says yes. An InfoBar is by definition non-blocking, so |
| 26 | + moving one there means performing the action and reporting it afterwards. |
| 27 | + That is a change in security posture, not in presentation. **Dropped** |
| 28 | + (§4, signed off 2026-08-29); the mechanism for a user who finds a prompt |
| 29 | + noisy already exists and is config, not UI. |
| 30 | + |
| 31 | +3. **"New strings ×8 locales."** Stands, and the count is in §3.5. |
| 32 | + |
| 33 | +The correction the measurement points at: **P6 is a consolidation, not an |
| 34 | +addition.** Its value is that the three banners stop being three. |
| 35 | + |
| 36 | +--- |
| 37 | + |
| 38 | +## 1. Baseline measurement |
| 39 | + |
| 40 | +### 1.1 The three banners |
| 41 | + |
| 42 | +| Surface | State field | Trigger | Dismiss | Colour | |
| 43 | +|---|---|---|---|---| |
| 44 | +| Update | `update_banner: Option<String>` | GitHub Releases poll, 5 s after start | `Esc`; `Enter` opens the release page | `semantic_success` | |
| 45 | +| Offline | `offline_banner_since: Option<Instant>` | IPC not yet connected | clears on connect (`lifecycle.rs:313`) | `semantic_warning` | |
| 46 | +| Error | `error_banner: Option<String>` | `ServerToClient::Error` — PTY launch failure, config load error, split failure | `Esc` | `semantic_error` | |
| 47 | + |
| 48 | +Three builders in `ui_verts.rs`, **79 + 63 + 90 = 232 lines**, structurally |
| 49 | +identical: pick a colour, compute `bar_y`, call `draw_banner_bg`, correct the |
| 50 | +label against the returned ground, emit one string. The stacking is open-coded |
| 51 | +in each — the error banner re-derives its offset by testing the other two: |
| 52 | + |
| 53 | +```rust |
| 54 | +let mut bar_y = 0.0_f32; |
| 55 | +if state.update_banner.is_some() { bar_y += bar_h; } |
| 56 | +if state.offline_banner_since.is_some() { bar_y += bar_h; } |
| 57 | +``` |
| 58 | + |
| 59 | +A fourth banner means editing three functions. This is the same shape the P1 |
| 60 | +widget layer was built to remove, one layer up. |
| 61 | + |
| 62 | +### 1.2 Accessibility: the most important banner is invisible |
| 63 | + |
| 64 | +| Surface | AccessKit node | In `tree_state_hash` | In the SR alert region | |
| 65 | +|---|---|---|---| |
| 66 | +| Update | ✅ `build_update_banner_node`, `Role::Alert` | ✅ | — | |
| 67 | +| Offline | ❌ **none** | ✅ (`is_some()`) | — | |
| 68 | +| Error | ❌ **none** | ❌ **absent** | ❌ | |
| 69 | + |
| 70 | +`error_banner` occurs **zero times** in `accessibility.rs`. It is the surface |
| 71 | +that reports *"your shell could not be launched"* and *"your config failed to |
| 72 | +load"*, and a screen-reader user is never told. It is not in the tree hash |
| 73 | +either, so even the generic rebuild that might have caught it does not fire. |
| 74 | + |
| 75 | +The SR alert region (`state.alerts`) does not cover the gap: `AlertKind` has two |
| 76 | +variants, `Bell` and `Notification`, and `add_alert` has exactly two production |
| 77 | +call sites (`server_message.rs:99`, `lifecycle.rs:489`). |
| 78 | + |
| 79 | +`offline_banner_since` is the inverse defect — it is hashed, so its appearance |
| 80 | +forces a tree rebuild, but no node is ever added. The rebuild buys nothing. |
| 81 | + |
| 82 | +### 1.3 No motion |
| 83 | + |
| 84 | +The banners are not among the ten surfaces P3b1 gave `SurfaceMotion`. They pop |
| 85 | +in and out on a frame boundary, and `ClientState::has_active_animation` — the |
| 86 | +single place that decides whether to request another frame — knows nothing about |
| 87 | +them. Every other overlay in the product animates; these three do not. |
| 88 | + |
| 89 | +### 1.4 No auto-dismissal |
| 90 | + |
| 91 | +The roadmap's word is "auto-dismissing". None of the three are. Update and error |
| 92 | +persist until `Esc`; offline persists until the connection succeeds. An error |
| 93 | +from a transient failure stays on screen indefinitely. |
| 94 | + |
| 95 | +### 1.5 They occlude chrome, and content, without reflow |
| 96 | + |
| 97 | +All three draw from `y = 0`, and `grid_offset_y` is `tab_bar_h + padding_y` — |
| 98 | +unchanged by their presence (`render_frame.rs:280`). The banners are built at |
| 99 | +lines 1212 / 1233 / 1253, *after* the tab bar at line 876, so they draw over it. |
| 100 | + |
| 101 | +With the default `tab_bar.height = 32` and a bar of `cell_h * 1.4`, one banner |
| 102 | +roughly covers the tab bar. All three stack to about three times that, covering |
| 103 | +the tab bar and roughly two rows of terminal output — which are **not reflowed |
| 104 | +and not scrolled**, only hidden. There is no cap on the stack. |
| 105 | + |
| 106 | +--- |
| 107 | + |
| 108 | +## 2. Decisions |
| 109 | + |
| 110 | +### D1 — Consolidate; do not add |
| 111 | + |
| 112 | +One `InfoBar` stack owns every non-blocking status message. The three existing |
| 113 | +banners become three *kinds* of the same surface, not three surfaces. A fourth |
| 114 | +message type then costs one enum arm, not a fourth builder. |
| 115 | + |
| 116 | +The gate that makes this stick is structural, not visual: after P6 there must be |
| 117 | +**no second place that computes a banner's `y`**. |
| 118 | + |
| 119 | +### D2 — Overlay, do not reflow |
| 120 | + |
| 121 | +An InfoBar that pushes the grid down changes the terminal's row count, which |
| 122 | +means a PTY resize — a `SIGWINCH` and a full application redraw — every time a |
| 123 | +banner appears or disappears. For a surface that is supposed to be *calm*, and |
| 124 | +that can be triggered by a background poll, that is the wrong trade. The stack |
| 125 | +keeps overlaying. |
| 126 | + |
| 127 | +What changes is *what* it overlays: the stack moves **below the tab bar**. |
| 128 | +Chrome hiding chrome is the worse of the two costs — the tab bar is how the user |
| 129 | +navigates, and hiding it while an error is up is exactly when they need it. |
| 130 | + |
| 131 | +### D3 — Severity decides the dismissal policy |
| 132 | + |
| 133 | +| Kind | Severity | Auto-dismiss | Rationale | |
| 134 | +|---|---|---|---| |
| 135 | +| Error | error | **never** | It reports that something the user asked for did not happen | |
| 136 | +| Offline | warning | never (self-clearing) | Its whole content is "still not connected"; it ends when that ends | |
| 137 | +| Update | info | after a timeout | Purely informational, and it has a second life in the settings panel | |
| 138 | + |
| 139 | +"Auto-dismissing" from the roadmap therefore applies to exactly one of the three |
| 140 | +today. That is worth stating rather than building a timer every kind ignores. |
| 141 | + |
| 142 | +### D4 — Only the top bar carries an activation |
| 143 | + |
| 144 | +Today `Enter` opens the release page whenever `update_banner.is_some()`. With a |
| 145 | +stack, "the bar `Enter` acts on" has to be defined rather than inherited. It is |
| 146 | +the **top** bar, and only if its kind has an activation at all — so `Enter` does |
| 147 | +nothing while an error bar sits above the update bar. |
| 148 | + |
| 149 | +This is a behaviour change hiding inside a refactor, which is why it is a |
| 150 | +decision here and not something to discover during P6b. It is cheap to revisit |
| 151 | +in P6a if the ordering in §3.2 turns out to bury the update bar too often. |
| 152 | + |
| 153 | +--- |
| 154 | + |
| 155 | +## 3. Design |
| 156 | + |
| 157 | +### 3.1 State |
| 158 | + |
| 159 | +```rust |
| 160 | +/// A non-blocking status message. Fluent's InfoBar, not its Dialog or Flyout. |
| 161 | +pub struct InfoBar { |
| 162 | + pub kind: InfoBarKind, |
| 163 | + pub entrance: Timed, |
| 164 | + /// `Some` once the bar has been dismissed and is only being drawn out. |
| 165 | + pub exit: Option<Timed>, |
| 166 | + /// Wall-clock deadline for an auto-dismissing kind. |
| 167 | + pub expires_at: Option<Instant>, |
| 168 | +} |
| 169 | + |
| 170 | +pub enum InfoBarKind { |
| 171 | + UpdateAvailable { version: String }, |
| 172 | + Offline { since: Instant }, |
| 173 | + ServerError { message: String }, |
| 174 | +} |
| 175 | +``` |
| 176 | + |
| 177 | +`ClientState` keeps a single `info_bars: VecDeque<InfoBar>`, replacing the three |
| 178 | +`Option` fields. The three fields are **removed**, not wrapped — the same |
| 179 | +reasoning as P5's flat text tokens: leaving them lets a future call site keep |
| 180 | +setting a banner that the stack does not know about, and removal makes the |
| 181 | +compiler enumerate the sites. |
| 182 | + |
| 183 | +`InfoBarKind` carries severity (`fn severity(&self) -> Severity`) and its own |
| 184 | +`semantic_*` mapping, so the colour choice stops being open-coded per builder. |
| 185 | + |
| 186 | +### 3.2 Layout |
| 187 | + |
| 188 | +One function owns the stack: |
| 189 | + |
| 190 | +```rust |
| 191 | +/// Y of each visible bar, top-down, starting below the tab bar. |
| 192 | +fn bar_rects(bars: &[InfoBar], tab_bar_h: f32, cell_h: f32) -> Vec<Rect> |
| 193 | +``` |
| 194 | + |
| 195 | +Pure, so the cap and the ordering are unit-testable without a GPU. Ordering is |
| 196 | +by severity then by age, so an error never sits below an update notice. |
| 197 | + |
| 198 | +**Cap: two visible bars.** Beyond that the stack would eat terminal rows it does |
| 199 | +not own. A third and further bar is counted, not drawn, and the bottom bar gains |
| 200 | +a localised `"+{count} more"` suffix. §1.5's unbounded stack is the defect this |
| 201 | +closes. |
| 202 | + |
| 203 | +### 3.3 Accessibility |
| 204 | + |
| 205 | +Every bar gets an AccessKit node with `Role::Alert`, from one builder driven by |
| 206 | +`InfoBarKind` — the same "describe it once" shape the widget layer uses. This is |
| 207 | +what closes §1.2: the error banner becomes announceable for the first time. |
| 208 | + |
| 209 | +`tree_state_hash` gains the stack (kind discriminant + message + count). The |
| 210 | +elapsed-seconds text of the offline bar is deliberately *excluded* from the hash, |
| 211 | +preserving the existing comment's reasoning at `accessibility.rs:2276` — a |
| 212 | +per-second rebuild buys a screen reader nothing. |
| 213 | + |
| 214 | +### 3.4 Motion |
| 215 | + |
| 216 | +Each bar carries its own `Timed` entrance and a `(ghost, Timed)` exit, the |
| 217 | +`Option`-shaped pattern P3b1 established. The three-place checklist in |
| 218 | +`nexterm-client-gpu/CLAUDE.md` applies and is the likeliest thing to get wrong: |
| 219 | +the `has_active_animation` clause, the retire call in `lifecycle.rs`, and the |
| 220 | +`apply_surface_fade` around the builder. A bar that animates in but never |
| 221 | +retires leaves the event loop requesting frames forever. |
| 222 | + |
| 223 | +### 3.5 Strings |
| 224 | + |
| 225 | +Existing keys are reused where the text is unchanged — `update-available`, |
| 226 | +`offline-banner-connecting`, `error-banner-prefix`. **New: one key**, |
| 227 | +`infobar-more-count` (`"+{count} more"`), added to all 8 locales. |
| 228 | + |
| 229 | +The roadmap's "new strings ×8 locales" is therefore one string, not a set. The |
| 230 | +consolidation deliberately does not reword what the banners say; changing the |
| 231 | +copy and the architecture in the same PR would make a visual regression |
| 232 | +impossible to attribute. |
| 233 | + |
| 234 | +--- |
| 235 | + |
| 236 | +## 4. The consent reclassification — **dropped** (signed off 2026-08-29) |
| 237 | + |
| 238 | +> **Decision taken.** The maintainer accepted the recommendation below on |
| 239 | +> 2026-08-29: the roadmap's consent-reclassification bullet is dropped, and P6's |
| 240 | +> scope is the three banners only. No consent prompt moves out of a modal in |
| 241 | +> this phase, and no `ConsentKind` is touched. The reasoning is kept in full |
| 242 | +> because a future phase proposing the same change should have to answer it. |
| 243 | + |
| 244 | +The three `ConsentKind` variants are `OpenUrl`, `ClipboardWrite` and |
| 245 | +`Notification`. Each is asked *before* the action, and each is gated by a |
| 246 | +`ConsentPolicy` (`Allow` / `Deny` / `Prompt`, default `Prompt`) plus a |
| 247 | +per-session override. |
| 248 | + |
| 249 | +Moving any of them to an InfoBar means one of two things, and both are worse |
| 250 | +than the modal: |
| 251 | + |
| 252 | +- **Act, then notify.** The URL opens, the clipboard is written, or the |
| 253 | + notification is sent, and the user is told afterwards. That is a change in |
| 254 | + security posture disguised as a UI change. All three carry attacker-controlled |
| 255 | + content — a URL, clipboard text, notification text — from whatever is running |
| 256 | + in the pane. |
| 257 | +- **Keep it blocking, but draw it as a bar.** A surface that looks non-blocking |
| 258 | + but is not, which is worse than either honest option. |
| 259 | + |
| 260 | +There is no third reading in which "low-risk notice" describes any of them. |
| 261 | + |
| 262 | +**And the escape hatch already exists.** A user who finds a prompt noisy sets |
| 263 | +`osc_notification = "allow"` (or `external_url`, or `osc52_clipboard`) in |
| 264 | +`config.toml`. That is an explicit, per-category, persisted decision made |
| 265 | +outside the moment of attack — which is precisely what a security opt-out should |
| 266 | +be, and strictly better than a UI reclassification that would apply to everyone. |
| 267 | + |
| 268 | +Nor is there a currently-modal surface that *should* move: the other modals are |
| 269 | +`CloseWindowDialog` (destructive confirmation), `BlockNameModal` (input), |
| 270 | +`PasswordModal` (credential entry) and `FileTransferDialog` (progress + input). |
| 271 | +None is a passive notice. |
| 272 | + |
| 273 | +**Dropped.** If this is ever revisited it should be reopened as its own spec |
| 274 | +with a threat-model section, not folded into a presentation phase. |
| 275 | + |
| 276 | +Consequence for the phase: **P6 touches no security-relevant code.** The consent |
| 277 | +flow, `ConsentPolicy`, `ConsentKind`, `pending_consent` and its modal are all |
| 278 | +out of scope, and a P6 PR that edits any of them is out of scope by definition. |
| 279 | + |
| 280 | +--- |
| 281 | + |
| 282 | +## 5. Gates |
| 283 | + |
| 284 | +| Gate | Assertion | |
| 285 | +|---|---| |
| 286 | +| **G-single** | `bar_rects` is the only function computing a bar's `y`; a grep gate over `ui_verts.rs` finds no second stacking expression | |
| 287 | +| **G-a11y** | every `InfoBarKind` produces an AccessKit node with `Role::Alert` — exhaustive over the enum, so a new kind cannot be added without one | |
| 288 | +| **G-hash** | the tree hash changes when a bar is added, removed, or has its message changed, and **does not** change when only the offline bar's elapsed seconds advance | |
| 289 | +| **G-cap** | with N bars queued, at most 2 are drawn and the count suffix reports `N - 2` | |
| 290 | +| **G-order** | an error bar queued after an update bar is laid out above it | |
| 291 | +| **G-idle** | `has_active_animation` is false once every bar's entrance and exit have finished — the P3b1 failure mode, and the one that burns battery silently | |
| 292 | +| **G-i18n** | `infobar-more-count` is present in all 8 locale files | |
| 293 | + |
| 294 | +`G-a11y` is the gate that would have caught §1.2's defect, and its exhaustiveness |
| 295 | +over the enum is the point: the current three-field design has no shape that |
| 296 | +could have failed. |
| 297 | + |
| 298 | +--- |
| 299 | + |
| 300 | +## 6. PR breakdown |
| 301 | + |
| 302 | +| PR | Scope | Gate | |
| 303 | +|---|---|---| |
| 304 | +| **P6a** | `InfoBar` / `InfoBarKind` / `bar_rects` + the pure layout, ordering and cap tests. No call sites yet; the three banners still ship as they are. | G-single, G-cap, G-order | |
| 305 | +| **P6b** | Migrate the three banners onto the stack; remove the three `Option` fields and the three builders. Compiler-driven. | full suite; visual parity is the risk | |
| 306 | +| **P6c** | AccessKit nodes + tree hash + the `infobar-more-count` string ×8. | G-a11y, G-hash, G-i18n | |
| 307 | +| **P6d** | Motion and auto-dismissal for the info severity. | G-idle | |
| 308 | + |
| 309 | +P6b is the risk concentration, as P5b was: wide, mechanical, and the only PR that |
| 310 | +can change what the user sees by accident rather than by design. |
| 311 | + |
| 312 | +P6c could be folded into P6b, and is kept separate deliberately — the |
| 313 | +accessibility fix is the most valuable change in this phase and should be |
| 314 | +reviewable without the migration diff around it. |
| 315 | + |
| 316 | +--- |
| 317 | + |
| 318 | +## 7. Verification |
| 319 | + |
| 320 | +- **Measured, in CI**: the §5 gates. |
| 321 | +- **Not covered**: appearance. P6 moves the stack below the tab bar and changes |
| 322 | + what is occluded, which no headless test can judge. This lands on the same |
| 323 | + backlog as P4 and P5, and the recommendation from the P5 spec still stands: a |
| 324 | + single on-device pass covering P4 + P5 + P6 before the phase is called done. |
| 325 | +- **Specifically worth looking at on device**: three bars stacked at once with a |
| 326 | + small window, where the cap and the count suffix are the only thing between the |
| 327 | + stack and the terminal content. |
| 328 | + |
| 329 | +--- |
| 330 | + |
| 331 | +## 8. Open questions |
| 332 | + |
| 333 | +None blocking. Both questions this spec opened were closed before implementation: |
| 334 | + |
| 335 | +- The consent reclassification was a genuine fork and is now settled — dropped, |
| 336 | + §4, signed off 2026-08-29. P6's scope is the three banners. |
| 337 | +- The `Enter` binding under a stack became D4 rather than staying a question: |
| 338 | + the top bar carries the activation, and only if its kind has one. |
| 339 | + |
| 340 | +What remains is not a question but a **known limit**: P6 changes what the top of |
| 341 | +the screen occludes, and no headless test can judge that (§7). It joins the P4 |
| 342 | +and P5 on-device backlog rather than blocking the phase. |
0 commit comments