Skip to content

Commit 8a51a6f

Browse files
mizu-junclaudehappy-otter
authored
docs+feat: P6 design spec and the InfoBar foundation (UI/UX v3 P6a) (#92)
* docs(plans): design spec for UI/UX v3 P6 (notification surfaces) Measures the current state before planning, as the P4 and P5 specs did, and two of the roadmap's three bullets do not survive it. There is no "update-banner slot pattern" to reuse. Nexterm already ships three top-of-screen banners — update, offline, error — with three state fields, three structurally identical builders totalling 232 lines, and the stacking offset open-coded in each (the error banner re-derives its own by testing whether the other two are visible). P6 is therefore a consolidation, not an addition: a fourth message type currently costs an edit to three functions. The measurement also turned up an accessibility defect worth more than the refactor. `error_banner` occurs zero times in accessibility.rs — no node, not in the tree hash, and not covered by the SR alert region, whose AlertKind has only Bell and Notification. That is the surface reporting "your shell could not be launched" and "your config failed to load", and a screen-reader user is never told. `offline_banner_since` is the inverse: hashed, so it forces a tree rebuild, but no node is ever added. Only the update banner has one. Three further findings: the banners are not among P3b1's ten animated surfaces, so they pop; none auto-dismisses despite the roadmap's wording; and all three draw from y=0 after the tab bar is built, so they occlude it, with no cap on the stack and no reflow of the terminal rows they cover. The consent-reclassification bullet is recommended dropped, and the spec marks it as needing explicit sign-off rather than settling it. All three ConsentKind variants are pre-action authorisations carrying attacker-controlled content; moving one to a non-blocking surface means either acting first and reporting afterwards, or drawing a blocking surface as if it were not. The existing per-category ConsentPolicy = "allow" is the correct opt-out for a user who finds a prompt noisy, and no other currently-modal surface is a passive notice. "New strings ×8 locales" turns out to be one string. 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> * docs(plans): sign off P6 §4 — consent reclassification dropped The maintainer accepted the recommendation: the roadmap's consent-reclassification bullet is dropped, and P6's scope is the three banners. No consent prompt moves out of a modal, and no ConsentKind is touched — P6 therefore edits no security-relevant code, which is now stated as a scope boundary a PR can be checked against. The reasoning stays in the spec in full rather than being deleted with the question, so a future phase proposing the same change has to answer it. Also promotes the spec's second open question to a decision (D4) so nothing blocks implementation: with a stack, Enter acts on the top bar and only if that bar's kind has an activation. That is a behaviour change hiding inside a refactor, so it is settled here rather than discovered during the migration PR. §8 now records a known limit — P6 changes what the top of the screen occludes, which no headless test can judge — instead of open questions. 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> * feat(client): InfoBar model and pure stack layout (UI/UX v3 P6a) Nexterm ships three top-of-screen banners — update, offline, error — each with its own state field, its own builder and its own hand-written stacking arithmetic, so the error banner re-derives its `y` by testing the other two and a fourth message type would mean editing three functions. This is the foundation of the consolidation: one `InfoBarKind` enum carrying severity, colour, activation and dismissal policy; one `InfoBar` value; and `bar_rects`, the single function that computes where a bar sits. Ordering is by severity then by age, so an error never sits below an update notice, and the stack is capped at two drawn bars with the rest counted — the unbounded stack was eating terminal rows that are neither reflowed nor scrolled. No call sites yet: the three banners still ship exactly as they are, and P6b migrates them onto the stack. 14 pure unit tests cover the ordering (G-order), the cap (G-cap), the tab-bar offset, the D3 dismissal policy and the D4 top-bar activation. G-single — the grep gate asserting no second stacking expression — cannot pass until the old builders are gone in P6b. Spec: docs/plans/2026-08-29-p6-notification-surfaces.md §3.1–3.2, §6. 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> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Happy <yesreply@happy.engineering>
1 parent df3876f commit 8a51a6f

4 files changed

Lines changed: 798 additions & 4 deletions

File tree

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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+
contenta URL, clipboard text, notification textfrom 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 attackwhich 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 finishedthe 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 deliberatelythe
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 settleddropped,
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 that7). It joins the P4
342+
and P5 on-device backlog rather than blocking the phase.

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,12 +266,29 @@ contextual UI; InfoBar = non-blocking status.
266266

267267
- New `overlay/infobar.rs`: non-blocking, auto-dismissing banner (reuses the
268268
update-banner slot pattern in `ClientState`).
269-
- Reclassify `ConsentDialog` kinds: keep genuinely destructive/security
270-
confirmations modal; move low-risk notices to InfoBar. Consent-surface
271-
changes are security-sensitive — review scope carefully before moving any
272-
prompt out of a modal.
269+
- ~~Reclassify `ConsentDialog` kinds: keep genuinely destructive/security
270+
confirmations modal; move low-risk notices to InfoBar.~~ **Dropped
271+
2026-08-29** — see the design spec §4. All three consent kinds are pre-action
272+
authorisations carrying pane-controlled content; moving one to a non-blocking
273+
surface changes security posture rather than presentation, and
274+
`ConsentPolicy = "allow"` is already the per-category opt-out.
273275
- New strings ×8 locales.
274276

277+
> **Design spec (2026-08-29):**
278+
> `plans/2026-08-29-p6-notification-surfaces.md`. It corrects two of the three
279+
> bullets above. There is no single "update-banner slot" to reuse: Nexterm
280+
> already ships **three** top-of-screen banners (update / offline / error) with
281+
> three state fields, three builders totalling 232 lines, and hand-written
282+
> stacking arithmetic in each — so P6 is a **consolidation, not an addition**.
283+
> The measurement also found an accessibility defect: `error_banner` appears
284+
> **zero times** in `accessibility.rs`, so shell-launch and config-load failures
285+
> are never announced. The consent-reclassification bullet is **recommended
286+
> dropped** — all three consent kinds are pre-action authorisations, and moving
287+
> one to a non-blocking surface changes security posture rather than
288+
> presentation; `ConsentPolicy = "allow"` is the existing opt-out. **Signed off
289+
> 2026-08-29**, so P6 touches no security-relevant code. "New strings ×8" turns
290+
> out to be one string. Four PRs P6a–P6d.
291+
275292
### P7 — Full custom title bar (spike S, then XL)
276293

277294
Principle: Familiar (WT-style tabs-in-titlebar silhouette). High risk —

0 commit comments

Comments
 (0)