From 660a11ad421c3ee06ae32f1655ef727033e4f892 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:47:53 +0200 Subject: [PATCH 01/22] docs(overlay): requirements + UX spec for blocking progress overlay Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md new file mode 100644 index 000000000..f20452fa2 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -0,0 +1,447 @@ +# Blocking Progress Overlay — Requirements + UX Specification + +**Phase:** 1a (Requirements) + 1b (UX) — combined +**Author:** Diziet (Product Designer) +**Date:** 2026-06-17 +**Status:** Draft for downstream phases (architecture decision belongs to Nagatha in 1d) +**Sibling component:** `MessageBanner` (`src/ui/components/message_banner.rs`) + +--- + +## 0. Executive Summary + +Some operations in Dash Evo Tool are not safe to interrupt and are not meaningful to +interact *around*: broadcasting a state transition, signing, importing keys, a multi-step +identity registration, a migration step. For these, a passive banner is the wrong tool — +the user can still click into half-finished state, fire a second conflicting operation, or +simply not notice that the app is busy. + +This spec defines a **full-screen blocking progress overlay**: a sibling capability to +`MessageBanner` that draws a dimming plane over the *entire* window, blocks all interaction +beneath it, and shows a "please wait" message with an **indeterminate spinner (no ETA)**, an +**optional step counter** (`Step {current} of {total}`), and **optional action buttons** +(at minimum Cancel). It is dismissed programmatically when the operation completes, or by an +optional Cancel button. + +**Critical invariant (learned the hard way — PR860):** the overlay is a *visual + input* +block only. It must never synchronously wait in the egui frame loop. The real work runs on a +tokio backend task; the overlay is raised when the task is dispatched and lowered when the +`TaskResult` arrives. A synchronous wait here deadlocks rendering. + +**Headline recommendation (detailed in §6):** build a **new standalone component** that +*mirrors* `MessageBanner`'s architecture (global state in egui `ctx.data`, a lifecycle +handle, an action-id queue, log-once discipline, theme tokens) — but do **not** extend +`MessageBanner` itself. The two have opposite z-order, opposite blocking semantics, and a +different render seam. + +--- + +## 1. Personas Affected + +All three personas (`docs/personas/`) hit long, uninterruptible operations; each experiences +the overlay differently. + +| Persona | How they meet the overlay | What they need from it | +|---|---|---| +| **Alex — Everyday User** (low/moderate technical) | Registering a DPNS name; sending Dash; first wallet sync. Alex does not know *what* a "state transition" is and should never see that phrase. | A calm plain-language sentence ("Registering your username. This can take up to a minute."), a spinner that clearly says *working, not frozen*, and — when offered — one obvious Cancel. No jargon, no error codes. | +| **Priya — Power User** (operator) | Asset-lock → fund identity flows; credit transfers; withdrawals. Runs several operations a session and wants to know *which step* she is on. | The step counter (`Step 3 of 5`) so a multi-stage flow is legible; confidence that Cancel is safe; the operation description precise enough to trust. | +| **Jordan — Platform Developer** | Bulk identity creation, contract deploys, repeated testnet iterations. Hammers the app during sprints. | Fast, honest feedback. A spinner that doesn't pretend to know an ETA it can't compute. If something hangs, an escape hatch so a wedged test run doesn't trap the whole app. Step counter for the compound "asset lock → proof → register → fund" chain. | + +**Cross-persona truth:** an indeterminate spinner with *no fake progress bar* is the honest +choice — we frequently cannot know how long a Platform round-trip takes. A counterfeit +percentage erodes trust faster than an honest "this is working." Validated first against Alex +(least technical): if Alex understands "the app is busy and will tell me when it's done," the +others are covered. + +--- + +## 2. Functional Requirements + +Each FR is written so it can be acceptance-tested. "Overlay" = the blocking progress overlay. + +### FR-1 — Show the overlay +A caller can raise the overlay with a single call that returns a lifecycle **handle** +(mirroring `BannerHandle`). The call accepts at minimum a description string and a config +(spinner always on; counter, buttons optional). +**AC-1.1** After a show call, the overlay is visible on the next frame, centered, over the whole window. +**AC-1.2** The call returns a handle usable to update or dismiss the overlay later. +**AC-1.3** The show call is safe to issue from a screen's `ui()` return path or from the app loop; it never blocks the calling thread. + +### FR-2 — Replace / update overlay content +The handle can update the description, the step counter, and the button set **in place** +without tearing the overlay down or restarting the spinner. +**AC-2.1** Updating the description changes the visible text on the next frame; the spinner does not flicker or reset. +**AC-2.2** Updating the counter from `2 of 5` to `3 of 5` changes only the counter line. +**AC-2.3** A stale handle (overlay already dismissed) is a no-op returning `None` — never a panic. + +### FR-3 — Hide the overlay +The overlay is dismissed (a) programmatically via the handle, or (b) by the app loop when +the owning operation's `TaskResult` arrives. +**AC-3.1** On dismissal the overlay disappears next frame and interaction beneath is fully restored. +**AC-3.2** Dismissing an already-dismissed overlay is a no-op. +**AC-3.3** When the overlay is dismissed because a task failed, the resulting error is shown via `MessageBanner` *after* the overlay is gone (clean hand-off — see FR-9). + +### FR-4 — Indeterminate spinner, no ETA +The overlay always shows an animated indeterminate spinner. It **must not** render any +time-derived percentage, progress bar, or ETA. +**AC-4.1** The spinner animates continuously while visible (`egui::Spinner`, which self-requests repaint — no custom per-frame timer). +**AC-4.2** No element expresses "X% complete" or "N seconds remaining" derived from elapsed time. +**AC-4.3** An *optional* honest elapsed-time readout (`Elapsed: {seconds}s`, counting up, never counting down) MAY be shown for reassurance on very long waits — this is not an ETA and is off by default. + +### FR-5 — Optional step counter (determinate, discrete) +For multi-step operations, the overlay MAY show a discrete step counter. +**AC-5.1** When a counter is set, the overlay shows a single i18n-ready line: `Step {current} of {total}` (named placeholders, no fragment concatenation). +**AC-5.2** `current` and `total` are positive integers; `current ≤ total`. An invalid pair (e.g. `0 of 0`, `4 of 3`) hides the counter rather than rendering nonsense. +**AC-5.3** The counter is independent of the spinner — the spinner stays indeterminate even when a counter is present (a step counter is *not* a progress percentage). +**AC-5.4** A counter is optional: spinner-only overlays render no counter line and reserve no empty space for it. + +### FR-6 — Description text +The overlay MAY show a description of the operation in progress. +**AC-6.1** The description is a complete, plain-language sentence (i18n unit; see NFR-2). +**AC-6.2** Long descriptions wrap; they do not clip or force the window off-screen (scroll within the overlay card if needed). +**AC-6.3** A description is optional; spinner-only is valid (purely informational block with no text is permitted but discouraged for Alex's sake). + +### FR-7 — Optional action buttons (Cancel + generic actions) +The overlay MAY show zero or more action buttons. Cancel is the canonical one but the +mechanism is generic. +**AC-7.1** Buttons are optional. With none, the overlay is a pure block dismissed only programmatically. +**AC-7.2** Each button carries a label (i18n unit) and an opaque **action id**. Clicking pushes the action id into an overlay-action queue that the app loop drains and dispatches — exactly mirroring `BannerHandle::with_action` / `MessageBanner::take_action`. The overlay never calls backend code directly (UI-only seam; see NFR-1 and §6). +**AC-7.3** A Cancel button uses a well-known action id; the app loop maps it to the operation's cancellation path. +**AC-7.4** Buttons follow project button order (Confirm/primary RIGHT, Cancel LEFT) and use `StyledButton`/`ComponentStyles`, never bare `ui.button()`. +**AC-7.5** Cancel SHOULD be offered only when the operation is genuinely cancelable (see Risk R-3). When it cannot truly cancel, do not show a button that lies. + +### FR-8 — Block all interaction beneath +While visible, the overlay blocks every interactive element beneath it — central content, +left navigation panel, and top panel — and consumes keyboard input not directed at the +overlay's own controls. +**AC-8.1** Pointer clicks/drags on any region outside the overlay's own buttons have no effect on the UI beneath. +**AC-8.2** Keyboard input (Tab, Enter, typing) does not reach widgets beneath the overlay. (Do not rely on `Ui::set_enabled()` — deprecated in egui 0.33; use a top input-capturing layer instead.) +**AC-8.3** The block covers the *entire* window, including top and left panels — therefore the overlay renders at the `AppState` level, not inside `island_central_panel()` (which only wraps central content). See §3. +**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. + +### FR-9 — Coexistence with MessageBanner (z-order + hand-off) +**AC-9.1** The overlay renders **above** all `MessageBanner` banners (banners live inside the island content area at a background layer; the overlay sits on a top layer). The overlay wins z-order. +**AC-9.2** Banners already on screen when the overlay appears are covered/dimmed; because banner state persists in `ctx.data`, they reappear intact when the overlay is dismissed. +**AC-9.3** For a single operation, overlay and result-banner are **temporally exclusive**: the overlay is up *while running*; on completion the overlay is dismissed and then the success/error banner is shown. AppState owns this hand-off so screens don't double-report. + +### FR-10 — Multiple operations requesting the overlay +There is a **single global overlay slot**, but requests are tracked so concurrent owners +behave predictably. +**AC-10.1** The overlay holds a small **stack of active requests** (each keyed by its handle). The overlay is visible while the stack is non-empty. +**AC-10.2** The **most-recently-shown** request is the one rendered (its description, counter, buttons). Earlier requests remain on the stack but are not rendered. +**AC-10.3** Each handle dismisses **only its own** entry. A stale/earlier handle dismissing does not lower an overlay still owned by a later request. The overlay fully clears only when the stack empties. +**AC-10.4** Rationale and caveat: because the overlay *blocks the UI*, a human cannot launch a second blocking operation — concurrent requests can only come from background/programmatic tasks and are a design smell. The stack model degrades gracefully (it never strands a running operation behind a prematurely-cleared overlay) but callers SHOULD avoid stacking blockers. A single "concurrent overlay" event is logged once, not per frame. +**AC-10.5** Only the topmost request's Cancel/actions are reachable; lower requests' actions become reachable when the top is dismissed. + +> **Decision deferred to Nagatha (1d):** stack (AC-10.1, recommended) vs. simple +> last-writer-replace vs. reject-second. The stack is the only model that never unblocks the +> UI while an operation is still running; the simpler models are acceptable if product +> guarantees no concurrent blockers. + +--- + +## 3. Render Seam (important — differs from MessageBanner) + +`MessageBanner::show_global()` is called *inside* `island_central_panel()` +(`src/ui/components/styled.rs:565`), i.e. within the central content island — it therefore +**cannot** cover the top panel or left navigation panel. + +The blocking overlay must cover **everything**. So it follows the **`AppState`-level render +pattern** already used by the just-in-time secret prompt +(`render_secret_prompt(ctx)`, `src/app.rs:1527`), which draws over all panels after they are +laid out. The overlay's *state* still lives globally in egui `ctx.data` (mirroring banners), +but its *render call* belongs at the end of `AppState::update()`, on a top input-capturing +layer — not in `island_central_panel()`. + +Layering target (top to bottom): + +``` + ┌─ secret-prompt / confirmation modals (must stay ABOVE overlay — see R-1) + ├─ BLOCKING PROGRESS OVERLAY (top input-capturing layer + dim plane) + ├─ MessageBanner banners (inside island content area) + └─ Top panel / Left panel / Central content +``` + +--- + +## 4. Non-Functional Requirements + +### NFR-1 — Never block the frame/render thread +The overlay is a visual + input block **only**. The owning operation runs on a tokio backend +task via the existing `BackendTask`/`TaskResult` channel; the overlay is raised at dispatch +and lowered when the result is polled in `AppState::update()`. +**AC:** No code path holds a lock across `.await` to keep the overlay up; no synchronous +sleep/wait in the frame loop. (Reference incident: PR860 — async blocking in the egui frame +loop deadlocked the UI.) + +### NFR-2 — i18n-ready strings +All overlay text is i18n-ready: complete sentences, named placeholders, no fragment +concatenation, no grammar assembled from pieces. +**AC:** Counter is one unit `Step {current} of {total}`; elapsed is `Elapsed: {seconds}s`; +descriptions are full sentences. No positional `format!` of sentence fragments. Matches the +project's i18n convention (Rust `{name}` specifiers today, Fluent `{ $name }` later). + +### NFR-3 — Accessibility (within egui's constraints) +**AC-3a (focus trap):** while the overlay is up, keyboard focus is confined to its own +controls; Tab does not cycle into widgets beneath. +**AC-3b (Esc):** Esc triggers Cancel **only when the overlay is cancelable** (a Cancel action +is present). With no Cancel, Esc is swallowed (does nothing) — it must not dismiss a +non-cancelable block. +**AC-3c (Enter):** Enter does not auto-confirm anything destructive; if a single primary +action exists, Enter MAY activate it, but Cancel is never the Enter default. +**AC-3d (not color-only):** "busy" is signalled by the moving spinner + text, not color alone. +**AC-3e (contrast):** text/buttons meet WCAG 2.1 AA (4.5:1 text, 3:1 UI) on the dimmed plane in both themes. +**AC-3f (known limitation):** egui has no screen-reader annotation support (per +`docs/ux-design-patterns.md` §10). Documented as a constraint; the moving spinner + visible +sentence are the available affordances. No false promise of SR announcements. + +### NFR-4 — Light + dark theme +**AC:** All colors via `DashColors` (dim plane via `modal_overlay()` = `rgba(0,0,0,120)`, +re-evaluated each frame so a theme switch mid-overlay is correct). Card uses +`surface`/`window_fill`, text via `text_primary`/`text_secondary`, spinner via `DASH_BLUE`, +buttons via `ComponentStyles`. Zero hardcoded `Color32`. + +### NFR-5 — No per-frame log spam +**AC:** State changes log **once** — on show, on each counter/description change, on dismiss — +guarded by a `logged`/last-logged flag in the stored state (mirroring `BannerState.logged`). +Rendering at ~60fps must not emit ~60 logs/sec. State lives in `ctx.data`, **not** a per-frame +reconstructed instance (avoids the "fresh instance each frame resets state + spams logs" +trap). + +### NFR-6 — Cheap render +**AC:** When no overlay is active, the render call is an early-out reading one `ctx.data` slot +(mirroring `show_global`'s empty check). No allocation on the idle path. + +--- + +## 5. User Journeys + +> **Usage rule (when to block vs. when to banner):** use the overlay only when continued +> interaction would be *unsafe or meaningless*. Long *background* work the user can safely +> ignore (e.g. ambient SPV sync, identity discovery sweeps) stays a non-blocking +> `MessageBanner::with_elapsed()` progress banner. Blocking the whole UI for ambient sync +> would punish Priya and Jordan, who legitimately work while syncing. + +### J-1 — Identity registration (multi-step, counter + Cancel) — Priya / Jordan +1. User confirms "Register identity." +2. Overlay appears: `Step 1 of 4` · "Preparing the funding lock." · Cancel. +3. Backend advances: handle updates to `Step 2 of 4` "Waiting for the funding proof.", then + `Step 3 of 4` "Registering your identity.", then `Step 4 of 4` "Funding your identity." +4. Spinner stays indeterminate throughout (each step's duration is unknown). +5. On the final `TaskResult::Success`, AppState dismisses the overlay, then shows a success + banner. On failure, overlay is dismissed, then an error banner appears (FR-9). + +### J-2 — Broadcasting / signing a transaction (spinner + description, often no Cancel) — all +1. User confirms a send / state-transition broadcast. +2. Overlay: spinner + "Sending your transaction to the network." No Cancel (a broadcast in + flight cannot be safely recalled — R-3). +3. Overlay lowers on result; banner reports outcome. + +### J-3 — Multi-step shielded operation (spinner + counter + description + Cancel) — Jordan/Priya +1. User starts a shield/unshield. +2. Overlay: `Step 2 of 3` · "Building the shielded transaction." · Cancel (note generation / + proving can be long and is locally cancelable before broadcast). +3. If the user cancels before broadcast, the Cancel action id is dispatched, the backend + aborts the local build, the overlay lowers, and an info banner notes "Operation canceled." + +### J-4 — Migration / key import (informational hard block, no buttons) — all +1. A one-shot migration step or sensitive key import runs. +2. Overlay: spinner + "Updating your wallet data. Please keep the app open." No buttons + (interrupting mid-migration risks inconsistent state). +3. Overlay lowers only when the step completes. (This is exactly the open question raised in + `docs/ai-design/2026-05-28-migration-tool/notes.md` — "Spinner with progress, modal + blocker, banner?" — this overlay is the answer for the blocking case.) + +### J-5 — Network switch (brief block) — Jordan +1. User switches Testnet ⇄ Devnet. +2. Brief overlay: spinner + "Switching networks." prevents interacting with stale + network state during the swap; lowers when the new context is ready. + +--- + +## 6. Interaction Patterns & Wireframes (ASCII) + +The dim plane (`modal_overlay()`) covers the whole window; a centered card holds the content. +The card uses the dialog idiom (rounded corners, shadow, `surface`/`window_fill`). + +### 6.1 Spinner-only (pure block) + +``` +┌───────────────────────────────────────────────────────────────┐ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░┌───────────────────────┐░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ (◠) │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ spinner │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░│ │░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░└───────────────────────┘░░░░░░░░░░░░░░░░░░░░│ +│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│ +└───────────────────────────────────────────────────────────────┘ + ░ = dimmed, input-blocked backdrop (entire window incl. panels) +``` + +### 6.2 Spinner + step counter + +``` +░░░░░░░░░░░░┌─────────────────────────────────┐░░░░░░░░░░░░ +░░░░░░░░░░░░│ (◠) │░░░░░░░░░░░░ +░░░░░░░░░░░░│ spinner │░░░░░░░░░░░░ +░░░░░░░░░░░░│ │░░░░░░░░░░░░ +░░░░░░░░░░░░│ Step 3 of 5 │░░░░░░░░░░░░ +░░░░░░░░░░░░└─────────────────────────────────┘░░░░░░░░░░░░ +``` + +### 6.3 Spinner + counter + description + Cancel (full) + +``` +░░░░░░░┌───────────────────────────────────────────────┐░░░░░░░ +░░░░░░░│ (◠) │░░░░░░░ +░░░░░░░│ spinner │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Step 2 of 4 │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Waiting for the funding proof. This can │░░░░░░░ +░░░░░░░│ take up to a minute. │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ [ Cancel ] │░░░░░░░ +░░░░░░░└───────────────────────────────────────────────┘░░░░░░░ +``` + +### 6.4 Two generic actions (Cancel left, primary right) + optional elapsed + +``` +░░░░░░░┌───────────────────────────────────────────────┐░░░░░░░ +░░░░░░░│ (◠) spinner │░░░░░░░ +░░░░░░░│ │░░░░░░░ +░░░░░░░│ Building the shielded transaction. │░░░░░░░ +░░░░░░░│ Elapsed: 23s │░░░░░░░ ← honest, counts UP, optional +░░░░░░░│ │░░░░░░░ +░░░░░░░│ [ Cancel ] [ Run in background ] │░░░░░░░ +░░░░░░░└───────────────────────────────────────────────┘░░░░░░░ +``` + +### 6.5 Interaction states + +| State | Behavior | +|---|---| +| Visible, no buttons | Pure block. Esc/Enter swallowed. Backdrop click ignored. Dismissed only programmatically. | +| Visible, cancelable | Esc → Cancel. Cancel button focusable and is the first focus stop. Backdrop click ignored. | +| Button hover/focus | Standard `StyledButton` hover (pointing-hand) + focus ring (`BORDER_WIDTH_THICK`, ≥3:1). | +| Counter update | Only the counter line changes; spinner uninterrupted. | +| Theme switch mid-overlay | Colors re-evaluate next frame; no stale palette. | +| Window resized very small | Card shrinks to a min width; long description scrolls within the card; never pushed off-screen. | +| Operation hangs (no result) | After a threshold, surface optional elapsed readout and (if safe) an escape hatch — see R-4. | + +--- + +## 7. UX Recommendation — Extend `MessageBanner` vs. New Standalone Component + +**Recommendation: a NEW standalone component that mirrors `MessageBanner`'s architecture, not +an extension of `MessageBanner`.** (Final architecture call is Nagatha's in 1d; this is the +UX/maintainability advisory.) + +### Why not extend `MessageBanner` +| Dimension | MessageBanner | Blocking overlay | Verdict | +|---|---|---|---| +| Blocking | Never blocks; user works around it | Blocks the entire UI | Opposite semantics | +| Multiplicity | Up to 5 stacked, all visible | One rendered at a time | Different model | +| Z-order / seam | Inside `island_central_panel()` content area | `AppState`-level top layer over all panels | Different render seam (§3) | +| Lifecycle | Severity-based auto-dismiss | Dismissed by task result or Cancel; never auto-times-out | Different lifecycle | +| Anatomy | Icon + text + dismiss + optional details/suggestion/action | Spinner + description + counter + optional buttons, centered card | Different anatomy | + +Folding all of this into `BannerState` would bloat every banner with +spinner/step/button-set/blocking fields that are meaningless for the 99% of banners that are +simple notices, and would entangle the central render path. It violates single-responsibility +and would make the well-understood banner harder to reason about. + +### What to reuse (mirror, don't merge) +The overlay should **copy the proven *patterns***, keeping its own type: +1. **Global state in egui `ctx.data` temp storage**, keyed by a dedicated id (e.g. + `__global_progress_overlay`) — same mechanism as `BANNER_STATE_ID`. +2. **A lifecycle `OverlayHandle`** mirroring `BannerHandle` (`set_description`, `set_step`, + `with_cancel`, `clear`), all returning `Option` and no-op on a dismissed overlay. +3. **An action-id queue** drained by the app loop, mirroring `with_action` / `take_action` + (`push_action`/`get_actions`/`set_actions`) — keeps the overlay UI-only; backend dispatch + stays in `AppState`. This is the i18n-clean, `ctx.data`-friendly equivalent of "callbacks": + storing closures in temp storage is awkward (not `Clone`); an opaque action id is the + established seam. +4. **Log-once discipline** via a `logged` flag (NFR-5). +5. **Theme tokens + button helpers** (`DashColors`, `ComponentStyles`, `StyledButton`). + +### Placement +Per the DET module-placement policy, it renders egui → it is a **component** in +`src/ui/components/` (e.g. `progress_overlay.rs`), with its `show_global(ctx)` invoked from +`AppState::update()` near `render_secret_prompt`. Non-rendering helpers stay out of it. + +A thin shared helper for the `ctx.data` get/set/clear plumbing *could* be factored out and +used by both components, but only if it reads cleanly — the volume of shared code is small, +and premature abstraction here would couple two intentionally-different widgets. Lean toward a +focused copy over a forced shared base; defer to Nagatha. + +--- + +## 8. Open Questions & Risks + +- **R-1 — Z-order vs. secret-prompt / confirmation modals.** If an operation behind the + overlay needs a passphrase mid-flight, the secret-prompt modal (`render_secret_prompt`) must + render **above** the overlay and stay interactive — otherwise the operation wedges. Per the + sign-time prompt design (gate-on-error + auto-retry, not mid-flight), the common case avoids + this, but the layer ordering must be explicit: secret prompt / confirmation dialog > overlay. + Decide and test. +- **R-2 — Concurrent blocking operations (FR-10).** Confirm the stack model vs. + last-writer-replace vs. reject-second. Stack is safest (never unblocks while a task runs); + simpler models need a product guarantee that blockers don't overlap. +- **R-3 — Does Cancel actually cancel?** The honesty of the Cancel button depends on the + `BackendTask` system supporting cooperative cancellation (cancel tokens / abortable tasks). + If a task cannot truly be aborted, Cancel can only *stop waiting* while the work continues — + which is misleading and unsafe (e.g. a broadcast). **Verify backend cancellation support + downstream.** Until then, show Cancel only for operations that are genuinely cancelable + (local pre-broadcast work); broadcasts/migrations should be button-less blocks. +- **R-4 — Stuck overlay / no TaskResult.** With an indeterminate spinner and no ETA, a hung + task could trap the user forever. Need a safety valve: after a threshold, reveal the optional + elapsed readout and — only where safe — an escape hatch ("This is taking longer than usual" + + a way out). Define the threshold and which operations get an escape hatch. +- **R-5 — Should the top-panel connection/network indicator remain readable?** A full dim + hides connection status. Decide whether the overlay should leave the connection indicator + legible (e.g. lighter dim on the top strip) so a user waiting on a network op can see the + network dropped. Leaning: keep the block total for simplicity, surface connection loss via + the post-dismissal banner; revisit if it confuses users. +- **R-6 — Accessibility ceiling.** egui exposes no screen-reader annotations + (`ux-design-patterns.md` §10). The moving spinner + visible sentence are the only + affordances; a non-sighted user gets no announced "busy." Documented limitation — flag if a + future AccessKit pass can announce overlay open/close. +- **R-7 — Tests.** kittest coverage should assert: input beneath is blocked, Esc cancels only + when cancelable, counter validation hides nonsense pairs, action id is enqueued on click and + drained FIFO, log-once, and dismiss-on-task-result hand-off to banner. Mirror the existing + `tests/kittest/message_banner.rs` style. + +--- + +## 9. Requirements Quality Checklist + +- ✅ Every persona has at least one journey (J-1…J-5 cover Alex, Priya, Jordan). +- ✅ Every FR has testable acceptance criteria. +- ✅ ≥3 real-life scenarios per major workflow (5 journeys, multiple step states). +- ✅ Edge/failure cases addressed (stale handle, hang, theme switch, tiny window, concurrent owners, failed task). +- ✅ Priorities/decisions flagged for the downstream owner (Nagatha) with rationale. +- ✅ Assumptions explicit (cancellation support, single-slot semantics, render seam). + +--- + +## 10. Notes for Downstream Phases + +- **Worktree/merge:** the requested first-action `git merge --ff-only 0484bcb6…` was a no-op — + local HEAD already sits at `0484bcb6` per `git status`. No shell was available in this design + session; all file/line references were read directly from the live working tree, so no + "re-verify after merge" caveat is needed for the citations. +- **Commit:** this design session had no shell access, so the doc could not be `git add`/ + committed automatically. The team lead should commit it: + `git add -A && git commit -m "docs(overlay): requirements + UX spec for blocking progress overlay"`. +- **Key source references (live tree):** `src/ui/components/message_banner.rs` (sibling + pattern), `src/ui/components/styled.rs:539-571` (`island_central_panel` render seam), + `src/app.rs:1527` (`render_secret_prompt` — the AppState-level modal render analog), + `src/ui/components/secret_prompt_host.rs` (global-modal-via-AppState pattern), + `src/ui/components/passphrase_modal.rs:128-156` (full-screen dim + centered window), + `src/ui/theme.rs:430` (`modal_overlay()`), `docs/ux-design-patterns.md` §8/§10/§11. +``` From 9f4efec412a2d6305902148cc454357a9f553061 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:55:50 +0200 Subject: [PATCH 02/22] docs(overlay): test case specification 49 TCs covering FR-1..FR-10, NFR-1..NFR-6, and R-7 kittest checklist. Items depending on the FR-10 concurrent-overlay architecture decision (stack vs. replace vs. reject) and the stuck-overlay threshold (R-4) are marked [depends on 1d] for Nagatha to resolve. Co-Authored-By: Claude Sonnet 4.6 --- .../02-test-spec.md | 1123 +++++++++++++++++ 1 file changed, 1123 insertions(+) create mode 100644 docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md new file mode 100644 index 000000000..03dfbb5fc --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -0,0 +1,1123 @@ +# Blocking Progress Overlay — Test Case Specification + +**Phase:** 1c (QA — Test Case Specification) +**Author:** Marvin (QA Engineer) +**Date:** 2026-06-17 +**Status:** Draft — pending architecture decision on FR-10 (see §4) +**Input:** `01-requirements-ux.md` (Requirements + UX Spec by Diziet) +**Style reference:** `tests/kittest/message_banner.rs` + +--- + +## 1. Overview + +This document specifies acceptance test cases for the **Blocking Progress Overlay** component. +Test cases are derived entirely from the requirements spec (`01-requirements-ux.md`); expected +behavior is defined by the spec, never by a yet-to-be-written implementation. + +Every FR (FR-1 through FR-10) and NFR (NFR-1 through NFR-6) is covered by at least one TC. +Items that depend on the architecture decision deferred to Nagatha (1d) are marked +**[depends on 1d]**. + +### 1.1 Test Type Key + +| Tag | Meaning | +|-----|---------| +| **kittest** | Implemented as an `egui_kittest` Harness test; assertable via `query_by_label`, rendered-widget tree, and `ctx.data` reads. Fast, deterministic, runs in CI. | +| **ctx.data** | Pure context-state assertion, no rendering; verifies `ctx.data` slot directly. Subtype of kittest. | +| **design-review** | Not directly automatable via kittest — must be verified by code inspection or human review. Noted with the specific invariant to check. | +| **integration** | Requires a full `AppState` frame loop (AppState-level render seam, task dispatch). Verifiable in the backend-e2e harness or a dedicated app-level kittest. | + +### 1.2 Naming Conventions Assumed + +The following public surface is assumed to exist (mirroring `MessageBanner`). Names are +illustrative; the architecture phase (1d) may adjust them. + +| Assumed name | Purpose | +|---|---| +| `ProgressOverlay::show_global(ctx, description, config)` | Raises the overlay; returns `OverlayHandle` | +| `ProgressOverlay::has_global(ctx)` | Returns `true` when an overlay is active | +| `ProgressOverlay::show_global_spinner_only(ctx)` | Convenience: spinner-only, no text | +| `ProgressOverlay::render_global(ctx)` | Render call from `AppState::update()` | +| `ProgressOverlay::take_actions(ctx)` | Drains the action-id queue (FIFO) | +| `OverlayHandle::set_description(text)` | Updates description; returns `Option<&Self>` | +| `OverlayHandle::set_step(current, total)` | Updates counter; returns `Option<&Self>` | +| `OverlayHandle::clear_step()` | Removes counter; returns `Option<&Self>` | +| `OverlayHandle::with_cancel(action_id)` | Adds Cancel button; returns `Option<&Self>` | +| `OverlayHandle::with_action(label, action_id)` | Adds generic button; returns `Option<&Self>` | +| `OverlayHandle::clear()` | Dismisses this handle's overlay entry | +| `OverlayHandle::is_active()` | Returns `true` if still on the overlay stack | +| `OVERLAY_CANCEL_ACTION_ID` | Well-known constant for the Cancel action id | + +--- + +## 2. Test Cases + +### Group A — Idle Path + +--- + +#### TC-OVL-001 — No overlay renders when no state is set +**Type:** kittest +**Traceability:** NFR-6 (cheap idle path) + +**Preconditions:** A fresh `egui::Context` with no overlay state written to `ctx.data`. + +**Steps:** +1. Build a Harness with `ProgressOverlay::render_global()` in the `build_ui` closure. +2. Call `harness.run()`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- No spinner widget, no description label, no step counter label, no button appears in the + rendered tree. +- The render call performs a single `ctx.data` read and returns immediately (no allocation; + verified by code inspection — see NFR-6 AC). + +--- + +### Group B — Show Lifecycle (FR-1) + +--- + +#### TC-OVL-002 — Overlay appears on the next frame after show +**Type:** kittest +**Traceability:** FR-1 (AC-1.1) + +**Preconditions:** Fresh context; no overlay active. + +**Steps:** +1. Inside `build_ui`, call `ProgressOverlay::show_global(ctx, "Registering your identity.", config_default())`. +2. Also call `ProgressOverlay::render_global(ctx)`. +3. Call `harness.run()`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `true`. +- A label containing `"Registering your identity."` is present in the rendered tree + (`harness.query_by_label("Registering your identity.").is_some()`). +- A spinner widget is present (query by egui `Spinner` widget type or by its accessibility label + if one is set — see implementation note in AC-3d). + +--- + +#### TC-OVL-003 — Show call returns a usable handle +**Type:** ctx.data +**Traceability:** FR-1 (AC-1.2) + +**Preconditions:** Fresh context. + +**Steps:** +1. Call `let handle = ProgressOverlay::show_global(&ctx, "Loading.", config_default())`. +2. Assert `handle.is_active()` returns `true`. +3. Call `handle.set_description("Updated text.")` and assert it returns `Some(&handle)`. +4. Assert `ProgressOverlay::has_global(&ctx)` returns `true`. + +**Expected outcome:** +All assertions pass. The handle is non-null and addresses a live overlay entry. + +--- + +#### TC-OVL-004 — Show call never blocks the calling thread +**Type:** design-review +**Traceability:** FR-1 (AC-1.3), NFR-1 + +**Invariant to verify during code review:** +`ProgressOverlay::show_global()` must not acquire any async lock, call `.await`, or issue a +`std::thread::sleep`. It must only write to `egui::ctx.data` (a synchronous, lock-guarded +`TypeMap`). The implementation must be callable safely from `Screen::ui()` or from the app +loop without any risk of yielding. + +**CI note:** Reference PR860 (deadlock caused by async blocking in egui frame loop). Any +`async fn`, `.await`, `Mutex::lock().await`, or `sleep` in the show path is a blocker. + +--- + +### Group C — Update In Place (FR-2) + +--- + +#### TC-OVL-005 — Description update changes text; spinner does not flicker +**Type:** kittest +**Traceability:** FR-2 (AC-2.1) + +**Preconditions:** Overlay active with description `"Preparing the funding lock."`. + +**Steps:** +1. Set overlay with description `"Preparing the funding lock."`. +2. Run one frame; assert label `"Preparing the funding lock."` is present. +3. Call `handle.set_description("Waiting for the funding proof.")`. +4. Run another frame. + +**Expected outcome:** +- Label `"Waiting for the funding proof."` is present. +- Label `"Preparing the funding lock."` is absent. +- The spinner widget is still present (same render path; no reset observable — no widget + re-creation that would reset the animation seed). +- `ProgressOverlay::has_global(ctx)` still returns `true`. + +--- + +#### TC-OVL-006 — Counter update changes only the counter line +**Type:** kittest +**Traceability:** FR-2 (AC-2.2) + +**Preconditions:** Overlay active with step `2 of 5`, description `"Processing."`. + +**Steps:** +1. Show overlay; set step `(2, 5)` and description `"Processing."`. +2. Run one frame; assert label `"Step 2 of 5"` is present. +3. Call `handle.set_step(3, 5)`. +4. Run another frame. + +**Expected outcome:** +- Label `"Step 3 of 5"` is present. +- Label `"Step 2 of 5"` is absent. +- Label `"Processing."` still present (description unchanged). +- Spinner still present. + +--- + +#### TC-OVL-007 — Stale handle update is a no-op returning None +**Type:** ctx.data +**Traceability:** FR-2 (AC-2.3) + +**Preconditions:** An `OverlayHandle` whose overlay has already been dismissed. + +**Steps:** +1. Show overlay; capture `handle`. +2. Call `handle.clear()` to dismiss. +3. Call `handle.set_description("After clear")`. +4. Call `handle.set_step(1, 3)`. +5. Call `handle.with_cancel(OVERLAY_CANCEL_ACTION_ID)`. + +**Expected outcome:** +- All handle method calls return `None`. +- `ProgressOverlay::has_global(ctx)` returns `false` (no new overlay created). +- No panic. + +--- + +### Group D — Dismiss (FR-3) + +--- + +#### TC-OVL-008 — Programmatic dismiss removes overlay and restores interaction +**Type:** kittest +**Traceability:** FR-3 (AC-3.1) + +**Preconditions:** Overlay is active. + +**Steps:** +1. Show overlay. +2. Run one frame; assert `has_global` is `true`. +3. Call `handle.clear()`. +4. Run another frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- No spinner, description, or counter label is present in the rendered tree. +- Widgets that were beneath the overlay are accessible again (not blocked — verified by + querying a sibling label that was previously beneath the dim plane and confirming it is + present and interactive). + +--- + +#### TC-OVL-009 — Double dismiss is a no-op +**Type:** ctx.data +**Traceability:** FR-3 (AC-3.2) + +**Preconditions:** Fresh context. + +**Steps:** +1. Show overlay; capture `handle`. +2. Call `handle.clear()` — assert returns without panic. +3. Call `handle.clear()` a second time — assert no panic. +4. Assert `ProgressOverlay::has_global(ctx)` returns `false`. + +**Expected outcome:** No panic; `has_global` is `false`. + +--- + +#### TC-OVL-010 — Dismiss on task failure: overlay gone before error banner appears +**Type:** integration +**Traceability:** FR-3 (AC-3.3), FR-9 (AC-9.3), J-1 / J-2 flow + +**Preconditions:** A simulated task that returns `TaskResult::Failure` is dispatched with an +overlay raised at dispatch time. + +**Steps:** +1. App loop dispatches task; raises overlay. +2. Simulate a `TaskResult::Failure` arriving in the `task_result_receiver`. +3. App loop polls result; in the same frame: + a. Dismisses the overlay (`handle.clear()`). + b. Calls `MessageBanner::set_global(ctx, error_message, MessageType::Error)`. +4. Render the frame via `render_global(ctx)` and `MessageBanner::show_global(ui)`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` returns `false`. +- `MessageBanner::has_global(ctx)` returns `true` with the error text. +- No frame exists where both the overlay and the error banner are visible simultaneously. + +--- + +### Group E — Spinner (FR-4) + +--- + +#### TC-OVL-011 — Spinner is always present when overlay is active +**Type:** kittest +**Traceability:** FR-4 (AC-4.1) + +**Preconditions:** Overlay raised in each of the three configurations: spinner-only, +spinner+counter, spinner+counter+description+button. + +**Steps:** For each configuration, call `show_global`, run one frame, query the rendered tree. + +**Expected outcome:** +An `egui::Spinner` widget (or widget with the spinner accessibility label, per implementation) +is present in all three configurations. The spinner does not require a custom per-frame repaint +timer — it self-requests repaint via egui's native animation clock. + +--- + +#### TC-OVL-012 — No ETA, progress bar, or percentage element present +**Type:** kittest +**Traceability:** FR-4 (AC-4.2) + +**Preconditions:** Overlay active with a description and step counter. + +**Steps:** +1. Show overlay with step `(2, 5)` and description `"Building the shielded transaction."`. +2. Run one frame; collect all rendered labels. + +**Expected outcome:** +- No label matches the pattern `*%*` (percentage). +- No label matches `*remaining*`, `*seconds left*`, `*ETA*`, or any time-countdown string. +- No `egui::ProgressBar` widget is present in the render tree. +- The step counter label `"Step 2 of 5"` is present (discrete, not a progress percentage). + +--- + +#### TC-OVL-013 — Optional elapsed readout is off by default; when on, counts up +**Type:** kittest +**Traceability:** FR-4 (AC-4.3) + +**Preconditions:** Fresh context. + +**Steps (Part A — default off):** +1. Show overlay with default config. +2. Run one frame. +3. Assert no label matching `"Elapsed:"` is present. + +**Steps (Part B — enabled):** +1. Show overlay; enable elapsed readout via config. +2. Run one frame; assert a label matching `"Elapsed: {seconds}s"` with `seconds ≥ 0` is present. +3. Advance the egui clock by 2 seconds; run another frame. +4. Assert the `seconds` value in the label is ≥ 2 and is not counting down. + +**Expected outcome:** +- Part A: no elapsed label present. +- Part B: label present; the `seconds` value increases monotonically across frames; it does + not count down from any target. + +--- + +### Group F — Step Counter (FR-5) + +--- + +#### TC-OVL-014 — Valid counter renders "Step {current} of {total}" +**Type:** kittest +**Traceability:** FR-5 (AC-5.1) + +**Preconditions:** Overlay raised with `set_step(3, 5)`. + +**Steps:** +1. Show overlay; set step `(3, 5)`. +2. Run one frame. + +**Expected outcome:** +- Exactly one label matching `"Step 3 of 5"` is present. +- The string is a single i18n unit with named placeholders rendered into it; it is not + constructed by concatenating `"Step "`, `"3"`, `" of "`, `"5"` as separate label segments + (design-review: verify the format string in the implementation is `"Step {current} of + {total}"` or equivalent single-unit string, not fragment concatenation). + +--- + +#### TC-OVL-015 — Invalid counter (0 of 0) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(0, 0)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(0, 0)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present in the rendered tree. +- The spinner and any description are still present. + +--- + +#### TC-OVL-016 — Invalid counter (current > total) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(4, 3)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(4, 3)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No panic. + +--- + +#### TC-OVL-017 — Invalid counter (current = 0, total > 0) hides the counter line +**Type:** kittest +**Traceability:** FR-5 (AC-5.2) + +**Preconditions:** Overlay raised; `set_step(0, 5)` called. + +**Steps:** +1. Show overlay; call `handle.set_step(0, 5)`. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No panic. + +--- + +#### TC-OVL-018 — Counter presence does not make the spinner determinate +**Type:** kittest +**Traceability:** FR-5 (AC-5.3) + +**Preconditions:** Overlay raised with `set_step(2, 4)`. + +**Steps:** +1. Show overlay with step `(2, 4)`. +2. Run one frame. + +**Expected outcome:** +- Label `"Step 2 of 4"` is present. +- An `egui::Spinner` widget is present (indeterminate — no `egui::ProgressBar` present). +- No percentage label is present. + +--- + +#### TC-OVL-019 — No counter line when counter is not set +**Type:** kittest +**Traceability:** FR-5 (AC-5.4) + +**Preconditions:** Overlay raised with description only; `set_step` never called. + +**Steps:** +1. Show overlay with description `"Sending your transaction to the network."` and no step. +2. Run one frame. + +**Expected outcome:** +- No label containing `"Step"` is present. +- No empty/blank row is reserved for the counter (no extraneous whitespace element). +- Description and spinner are present. + +--- + +### Group G — Description Text (FR-6) + +--- + +#### TC-OVL-020 — Description renders as a full plain-language sentence +**Type:** kittest +**Traceability:** FR-6 (AC-6.1) + +**Preconditions:** Overlay raised with description `"Registering your identity on the network."`. + +**Steps:** +1. Show overlay with the description string above. +2. Run one frame. + +**Expected outcome:** +- Label `"Registering your identity on the network."` is present as a single label (not split + across multiple egui labels — design-review: verify single `ui.label()` call with the full + string, not fragment concatenation). + +--- + +#### TC-OVL-021 — Long description wraps and does not clip or push off-screen +**Type:** kittest +**Traceability:** FR-6 (AC-6.2) + +**Preconditions:** A harness window narrower than the description text (e.g. 300 px wide). +Description is `"Waiting for the funding proof. This operation contacts the Dash network and may take up to two minutes depending on network conditions."`. + +**Steps:** +1. Build harness with `egui::vec2(300.0, 400.0)`. +2. Show overlay with the long description above. +3. Run one frame. + +**Expected outcome:** +- The label is present in the rendered tree (not clipped to `""` or empty). +- No egui `clip_rect` overflow warning fires (monitored via test output). +- The card and overlay remain within the window bounds (all widgets within `[0, 300] × [0, 400]`). + +--- + +#### TC-OVL-022 — Spinner-only overlay is valid (no description, no counter) +**Type:** kittest +**Traceability:** FR-6 (AC-6.3), FR-5 (AC-5.4) + +**Preconditions:** Overlay raised via `show_global_spinner_only(ctx)`. + +**Steps:** +1. Raise spinner-only overlay. +2. Run one frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `true`. +- Spinner widget is present. +- No description label is present. +- No step counter label is present. +- No button is present. + +--- + +### Group H — Buttons & Actions (FR-7) + +--- + +#### TC-OVL-023 — No buttons: overlay is a pure block, dismissed programmatically only +**Type:** kittest +**Traceability:** FR-7 (AC-7.1) + +**Preconditions:** Overlay raised with no buttons in config. + +**Steps:** +1. Show overlay with no `with_cancel` or `with_action` calls. +2. Run one frame. + +**Expected outcome:** +- No button widget is present in the rendered tree. +- `ProgressOverlay::take_actions(ctx)` returns an empty list. +- `ProgressOverlay::has_global(ctx)` is `true` (overlay persists — only `handle.clear()` can + lower it). + +--- + +#### TC-OVL-024 — Cancel button click enqueues the Cancel action id +**Type:** kittest +**Traceability:** FR-7 (AC-7.2, AC-7.3) + +**Preconditions:** Overlay raised with `handle.with_cancel(OVERLAY_CANCEL_ACTION_ID)`. + +**Steps:** +1. Show overlay with Cancel button. +2. Run one frame; assert a button with label `"Cancel"` is present. +3. Click the Cancel button (via `harness.get_by_label("Cancel").click()`). +4. Run another frame. +5. Call `ProgressOverlay::take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns a list containing exactly one entry equal to `OVERLAY_CANCEL_ACTION_ID`. +- The overlay itself is NOT automatically dismissed by the click — it remains until the app loop + dispatches the cancel action and explicitly calls `handle.clear()` (the overlay is UI-only; + it never calls backend code directly). + +--- + +#### TC-OVL-025 — Generic button click enqueues its action id +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**Preconditions:** Overlay raised with `handle.with_action("Run in background", "overlay.run_in_bg")`. + +**Steps:** +1. Show overlay with generic action button labelled `"Run in background"`. +2. Run one frame; assert button present. +3. Click the button. +4. Run another frame; call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns `["overlay.run_in_bg"]`. +- Overlay still active (not auto-dismissed). + +--- + +#### TC-OVL-026 — Action queue drains FIFO +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**Preconditions:** Overlay raised with a Cancel button and one generic button. + +**Steps:** +1. Show overlay with `with_cancel("cancel")` and `with_action("Secondary", "secondary")`. +2. Click Cancel; run one frame. +3. Click Secondary; run one frame. +4. Call `ProgressOverlay::take_actions(ctx)` once. + +**Expected outcome:** +- `take_actions` returns `["cancel", "secondary"]` in that order (FIFO). +- A second call to `take_actions` returns an empty list (queue drained). + +--- + +#### TC-OVL-027 — Button order: Cancel leftmost, primary action rightmost +**Type:** kittest (widget-position assertion) + design-review +**Traceability:** FR-7 (AC-7.4) + +**Preconditions:** Overlay with `with_cancel("cancel")` and `with_action("Run in background", "bg")`. + +**Steps:** +1. Show overlay; run one frame. +2. Query the widget rects for `"Cancel"` and `"Run in background"`. + +**Expected outcome:** +- The X coordinate of the Cancel button's rect is less than the X coordinate of the + `"Run in background"` button's rect (Cancel is to the left). +- Both buttons use `StyledButton` styling, not bare `ui.button()` (design-review: verify no + `ui.button()` call in the overlay renderer). + +--- + +### Group I — Input Blocking (FR-8) + +--- + +#### TC-OVL-028 — Pointer clicks on the dimmed backdrop have no effect on widgets beneath +**Type:** kittest +**Traceability:** FR-8 (AC-8.1) + +**Preconditions:** A test widget (a counter button labelled `"Increment"`) is rendered beneath +the overlay. Overlay has no buttons. + +**Steps:** +1. Render both the backdrop-blocking overlay and the `"Increment"` counter widget. +2. Simulate a pointer click at the position of the `"Increment"` button. +3. Run one frame. + +**Expected outcome:** +- The counter widget has not received the click event (counter value unchanged). +- `ProgressOverlay::take_actions(ctx)` returns empty (no overlay action triggered either). + +--- + +#### TC-OVL-029 — Keyboard input does not reach widgets beneath the overlay +**Type:** kittest +**Traceability:** FR-8 (AC-8.2) + +**Preconditions:** A text input widget is rendered beneath the overlay. Overlay has one Cancel +button. + +**Steps:** +1. Render both the overlay (with Cancel button) and a `TextEdit` widget beneath. +2. Type characters `"hello"` via `harness.key_press` or equivalent. +3. Run one frame. + +**Expected outcome:** +- The `TextEdit` content is unchanged — no characters were forwarded beneath the overlay. +- The overlay's Cancel button may have received focus (expected — the overlay captures input); + the `TextEdit` must not. + +**Implementation note (AC-8.2):** do not use `Ui::set_enabled(false)` (deprecated in egui +0.33). The spec requires a top input-capturing layer. Verify in design review that the +implementation uses `egui::Area` with `order(Order::Foreground)` or equivalent `ctx.input` +consumption, not `set_enabled`. + +--- + +#### TC-OVL-030 — Backdrop click does NOT dismiss the overlay +**Type:** kittest +**Traceability:** FR-8 (AC-8.4) + +**Preconditions:** Overlay active with no buttons. + +**Steps:** +1. Show overlay. +2. Run one frame; assert overlay active. +3. Simulate a pointer click on the dim plane (anywhere outside the card). +4. Run another frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is still `true`. +- No action was enqueued. + +--- + +#### TC-OVL-031 — Overlay renders at AppState level, covering all panels +**Type:** design-review +**Traceability:** FR-8 (AC-8.3), §3 Render Seam + +**Invariant to verify during code review:** +`ProgressOverlay::render_global(ctx)` must be called from `AppState::update()` after all +panels are laid out — not from inside `island_central_panel()`. The call site should be near +`render_secret_prompt(ctx)` at `src/app.rs:1527`. The overlay uses an `egui::Area` or +equivalent top layer to cover the entire viewport (top panel + left panel + central content), +not just the central content island. + +Verify: grepping for `render_global` in `src/app.rs` finds a call after panel rendering; +grepping for `render_global` in `src/ui/components/styled.rs` (`island_central_panel`) finds +**no** call. + +--- + +### Group J — Coexistence with MessageBanner (FR-9) + +--- + +#### TC-OVL-032 — Overlay renders above MessageBanner banners (z-order) +**Type:** design-review + integration +**Traceability:** FR-9 (AC-9.1) + +**Invariant:** +When both a banner and the overlay are active, the overlay's rendering layer (e.g. +`Order::Foreground` or `Order::Tooltip`) must be higher than the banner's layer (banner is +inside the central panel at default order). Verify by checking the `egui::Area::order()` used +for the overlay's dim plane; it must be above the order used for banner rendering. + +**Integration check (when available):** +In a full-app harness, add a banner and raise the overlay simultaneously. Assert that the +overlay's dim fills the expected region and the banner's label is not directly reachable +(obscured — `query_by_label` may still find the label in the widget tree even if dimmed; +the critical check is that the banner is behind the input-blocking layer). + +--- + +#### TC-OVL-033 — Banners persist in ctx.data while overlay is active; reappear on dismiss +**Type:** ctx.data +**Traceability:** FR-9 (AC-9.2) + +**Preconditions:** Two banners set via `MessageBanner::set_global`. + +**Steps:** +1. Set banners: `"Banner A"` (Error) and `"Banner B"` (Warning). +2. Raise the overlay. +3. Assert `MessageBanner::has_global(ctx)` is still `true` (banners survive in `ctx.data`). +4. Dismiss the overlay via `handle.clear()`; run one frame. +5. Call `MessageBanner::show_global(ui)`. + +**Expected outcome:** +- After dismiss, both `"Banner A"` and `"Banner B"` labels are present in the rendered tree. +- Banner state was not cleared or corrupted by the overlay lifecycle. + +--- + +#### TC-OVL-034 — Success task result: overlay dismissed before success banner shown +**Type:** integration +**Traceability:** FR-9 (AC-9.3), J-1 flow + +**Preconditions:** AppState dispatches a task; overlay is raised at dispatch time. + +**Steps:** +1. Dispatch a task; raise overlay. +2. Simulate `TaskResult::Success(result)` arriving on the receiver. +3. App loop frame: + a. Receives result. + b. Calls `handle.clear()` (overlay lowered). + c. Calls `MessageBanner::set_global(ctx, "Your identity has been registered.", MessageType::Success)`. +4. Render the frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `false`. +- `MessageBanner::has_global(ctx)` is `true` with the success text. +- No frame saw both active simultaneously (single-frame hand-off). + +--- + +#### TC-OVL-035 — Failed task result: overlay dismissed before error banner shown +**Type:** integration +**Traceability:** FR-3 (AC-3.3), FR-9 (AC-9.3) + +**Preconditions:** AppState dispatches a task; overlay is raised at dispatch time. + +**Steps:** +1. Dispatch a task; raise overlay. +2. Simulate `TaskResult::Error(err)` arriving on the receiver. +3. App loop frame: + a. Receives error. + b. Calls `handle.clear()`. + c. Calls `MessageBanner::set_global(ctx, "Registration failed. Try again.", MessageType::Error)`. +4. Render the frame. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `false`. +- `MessageBanner::has_global(ctx)` is `true` with an error-type banner. +- The error message is user-friendly (no SDK internals, no jargon — spot-check the display text + per the error message convention in `CLAUDE.md`). + +--- + +### Group K — Concurrent Operations (FR-10) — **[depends on 1d]** + +> All TCs in this group depend on the architecture decision (stack vs. replace vs. reject) +> deferred to Nagatha in Phase 1d. The TCs below are written for the **stack model** recommended +> in AC-10.1. If Nagatha selects a different model, these TCs must be revised. + +--- + +#### TC-OVL-036 — Topmost stack entry's content is rendered [depends on 1d] +**Type:** kittest +**Traceability:** FR-10 (AC-10.1, AC-10.2) + +**Preconditions:** Two overlay requests pushed. + +**Steps:** +1. Call `show_global(ctx, "Operation A.", cfg_a)` — capture `handle_a`. +2. Call `show_global(ctx, "Operation B.", cfg_b)` — capture `handle_b`. +3. Run one frame. + +**Expected outcome (stack model):** +- `ProgressOverlay::has_global(ctx)` is `true`. +- Label `"Operation B."` is present (topmost entry rendered). +- Label `"Operation A."` is absent (bottom entry not rendered). +- Both `handle_a.is_active()` and `handle_b.is_active()` return `true` (both on stack). + +--- + +#### TC-OVL-037 — Handle dismisses only its own stack entry [depends on 1d] +**Type:** ctx.data +**Traceability:** FR-10 (AC-10.3) + +**Preconditions:** Two overlay requests on the stack (A below, B on top). + +**Steps:** +1. Push handle_a, then handle_b. +2. Call `handle_b.clear()` (dismiss topmost). +3. Assert state. + +**Expected outcome (stack model):** +- `handle_b.is_active()` is `false`. +- `handle_a.is_active()` is `true`. +- `ProgressOverlay::has_global(ctx)` is `true` (A still on stack; overlay persists). +- On next render frame, `"Operation A."` label is present. + +--- + +#### TC-OVL-038 — Overlay clears only when the entire stack is empty [depends on 1d] +**Type:** ctx.data +**Traceability:** FR-10 (AC-10.3) + +**Preconditions:** Two entries on the stack. + +**Steps:** +1. Push handle_a and handle_b. +2. Call `handle_b.clear()` — assert `has_global` is still `true`. +3. Call `handle_a.clear()` — assert `has_global` is now `false`. + +**Expected outcome:** Overlay does not lower until the last entry is dismissed. + +--- + +#### TC-OVL-039 — Only topmost request's actions are reachable [depends on 1d] +**Type:** kittest +**Traceability:** FR-10 (AC-10.5) + +**Preconditions:** handle_a has Cancel button with id `"cancel_a"`; handle_b (on top) has +Cancel with id `"cancel_b"`. + +**Steps:** +1. Push handle_a then handle_b. +2. Run one frame. +3. Click Cancel button. +4. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns `["cancel_b"]` (topmost entry's action). +- `"cancel_a"` is not in the queue (lower entry's action unreachable while B is on top). + +--- + +#### TC-OVL-040 — Concurrent overlay event logged exactly once [depends on 1d] +**Type:** design-review +**Traceability:** FR-10 (AC-10.4) + +**Invariant:** +When a second `show_global` call is made while an overlay is already active, a single log +entry noting the concurrent request is emitted. Across subsequent frames where both remain on +the stack, no further log entry is emitted for the concurrency (log-once, guarded by flag — +mirrors `BannerState.logged`). + +Verify by code inspection: the `logged_concurrent` (or equivalent) flag in overlay state is +set on the first duplicate and checked before any `tracing::warn!` call on subsequent frames. + +--- + +### Group L — Accessibility (NFR-3) + +--- + +#### TC-OVL-041 — Focus trap: Tab does not cycle to widgets beneath the overlay +**Type:** kittest +**Traceability:** NFR-3 (AC-3a) + +**Preconditions:** Overlay active with one Cancel button. A text input widget exists beneath. + +**Steps:** +1. Show overlay with Cancel. +2. Run one frame; focus starts on Cancel. +3. Press Tab. +4. Run one frame. +5. Check focused widget. + +**Expected outcome:** +- Focus remains on the Cancel button (or returns to it if there is only one focusable element + in the overlay — Tab wraps within the overlay, not out of it). +- The `TextEdit` beneath does not receive focus. + +**Implementation note:** egui's default Tab behavior cycles through all focusable widgets +globally. The overlay must intercept Tab events when active (e.g. via consuming +`ctx.input_mut().events` or `ui.response().has_focus()` scoping). Design-review: verify Tab +events are not forwarded to `pass_events_to_game_while_any_popup_is_open = false` equivalent. + +--- + +#### TC-OVL-042 — Esc triggers Cancel when the overlay is cancelable +**Type:** kittest +**Traceability:** NFR-3 (AC-3b) + +**Preconditions:** Overlay raised with a Cancel button (`with_cancel(OVERLAY_CANCEL_ACTION_ID)`). + +**Steps:** +1. Show overlay with Cancel. +2. Run one frame. +3. Press Escape (`harness.key_press(egui::Key::Escape)`). +4. Run another frame. +5. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns `[OVERLAY_CANCEL_ACTION_ID]`. +- The Esc key event is consumed by the overlay (not forwarded further). + +--- + +#### TC-OVL-043 — Esc is swallowed when the overlay has no Cancel button +**Type:** kittest +**Traceability:** NFR-3 (AC-3b) + +**Preconditions:** Overlay raised with no buttons (pure block). + +**Steps:** +1. Show overlay with no buttons. +2. Run one frame. +3. Press Escape. +4. Run another frame. +5. Assert overlay still active; call `take_actions`. + +**Expected outcome:** +- `ProgressOverlay::has_global(ctx)` is `true` (Esc did NOT dismiss the overlay). +- `take_actions` returns empty. +- No action was dispatched. + +--- + +#### TC-OVL-044 — Enter does not activate Cancel +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** Overlay active with only a Cancel button; Cancel has focus. + +**Steps:** +1. Show overlay with Cancel; run one frame; ensure Cancel is focused. +2. Press Enter. +3. Run one frame. +4. Call `take_actions(ctx)`. + +**Expected outcome:** +- `take_actions` returns empty (Enter did NOT enqueue the cancel action). +- The overlay is still active. + +**Rationale:** Enter defaults are reserved for confirm/primary actions. Cancel is a secondary +action and must never be the Enter default — see AC-3c. + +--- + +### Group M — Non-Functional + +--- + +#### TC-OVL-045 — Log-once: state change logs once, not once per frame +**Type:** design-review + ctx.data +**Traceability:** NFR-5 + +**Invariant:** +1. On `show_global`: exactly one log entry at `debug` or `info` level noting overlay raised. + On subsequent frames with no state change, no further log entry for the overlay. +2. On `set_description` / `set_step` (content change): exactly one log entry. +3. On `handle.clear()`: exactly one log entry noting dismissal. + +**Verify by code inspection:** a `logged` (or `last_logged_description: Option`) flag +in the overlay state struct is set after the first log; subsequent render frames check the flag +before calling `tracing::debug!`. Pattern mirrors `BannerState.logged` in +`src/ui/components/message_banner.rs`. + +**Test (ctx.data check):** +1. Show overlay. +2. Capture the `logged` field value from `ctx.data`. +3. Assert it is `true` after the first render. +4. Simulate a second render pass without state change; assert `logged` is still `true` and + no new log entry was emitted. + +--- + +#### TC-OVL-046 — Theme switch mid-overlay re-evaluates all colors +**Type:** kittest +**Traceability:** NFR-4 + +**Preconditions:** Overlay active; harness configured for dark theme. + +**Steps:** +1. Show overlay in dark theme. +2. Run one frame; note the dim plane color is `modal_overlay()` = `rgba(0,0,0,120)`. +3. Switch the harness to light theme (call `ctx.set_visuals(egui::Visuals::light())`). +4. Run one frame. + +**Expected outcome:** +- The overlay continues to render without panic or stale palette. +- Colors for the card background, text, and dim plane are re-evaluated from `DashColors` and + `modal_overlay()` tokens each frame (design-review: grep for `Color32::from_rgb` or any + hardcoded color literal in `progress_overlay.rs` — must find zero). + +--- + +#### TC-OVL-047 — Stuck-overlay safety valve after inactivity threshold +**Type:** design-review (partially unspecified — flag for Nagatha) +**Traceability:** R-4 + +**What the spec defines:** +After an unspecified threshold with no `TaskResult` arriving, the overlay SHOULD: +1. Make the elapsed readout visible (if previously hidden). +2. Optionally offer an escape hatch ("This is taking longer than usual") for operations that + are safe to abandon. + +**What is NOT yet defined (flag for Nagatha / 1d):** +- The threshold duration (spec says "after a threshold" without a value). +- Which operation types get the escape hatch and which do not. +- Whether the escape hatch is a third button or replaces Cancel. +- Whether the elapsed readout activation is automatic or still requires an explicit config flag. + +**Partial test (assertable once threshold is defined):** +1. Show overlay with no elapsed readout; simulate time advancing past the threshold. +2. Run one frame. +3. Assert a label matching `"Elapsed: {seconds}s"` is present. +4. If the escape hatch is defined for this operation type, assert the escape hatch button is present. + +**⚠ This TC is incomplete until Nagatha defines the threshold and escape-hatch policy. Mark as +BLOCKED pending 1d.** + +--- + +#### TC-OVL-048 — Secret-prompt modal renders above the overlay (z-order R-1) +**Type:** integration + design-review +**Traceability:** R-1 + +**Preconditions:** Overlay is active; a secret-prompt (`render_secret_prompt`) is triggered +mid-operation. + +**Steps (design-review):** +1. In `src/app.rs::update()`, verify the call order: + - `ProgressOverlay::render_global(ctx)` is called. + - `render_secret_prompt(ctx)` is called **after** `render_global` (later in the same frame). +2. Because egui draws layers in call order (later `Area` calls render on top), the secret + prompt's `Area` with `Order::Foreground` (or `Order::Tooltip`) renders above the overlay. + +**Integration check (when available):** +In a full-app harness with both overlay and secret prompt active, assert that the passphrase +input widget is present and interactive (receives keyboard focus), while the overlay's dim is +present but not blocking the prompt. + +**Expected outcome:** +- Secret-prompt modal is interactable when overlay is active. +- The overlay does not intercept input intended for the secret prompt. + +--- + +#### TC-OVL-049 — NFR-1 frame-loop non-blocking invariant +**Type:** design-review +**Traceability:** NFR-1 + +**Invariant:** +The entire call path of `ProgressOverlay::render_global(ctx)` and +`ProgressOverlay::show_global(ctx, ...)` must be synchronous and non-blocking: +- No `.await`, no `async fn`, no `tokio::block_on`, no `std::thread::sleep` in the render or + show path. +- No `Mutex::lock().await` or `RwLock::write().await`. +- All `ctx.data` access uses egui's synchronous `TypeMap` locking (safe — same as `MessageBanner`). + +**Verify:** `cargo clippy` with `#[deny(clippy::async_yields_async)]` and a manual grep for +`block_on`, `sleep`, `.await` in `src/ui/components/progress_overlay.rs` and any module it +calls synchronously. Reference incident: PR860 (deadlock from async blocking in egui frame +loop). + +--- + +## 3. Requirement Coverage Matrix + +| Requirement | Covered by | +|---|---| +| FR-1 (show overlay) | TC-OVL-002, TC-OVL-003, TC-OVL-004 | +| FR-2 (update in place) | TC-OVL-005, TC-OVL-006, TC-OVL-007 | +| FR-3 (dismiss) | TC-OVL-008, TC-OVL-009, TC-OVL-010, TC-OVL-035 | +| FR-4 (spinner, no ETA) | TC-OVL-011, TC-OVL-012, TC-OVL-013 | +| FR-5 (step counter) | TC-OVL-014, TC-OVL-015, TC-OVL-016, TC-OVL-017, TC-OVL-018, TC-OVL-019 | +| FR-6 (description text) | TC-OVL-020, TC-OVL-021, TC-OVL-022 | +| FR-7 (buttons & actions) | TC-OVL-023, TC-OVL-024, TC-OVL-025, TC-OVL-026, TC-OVL-027 | +| FR-8 (input blocking) | TC-OVL-028, TC-OVL-029, TC-OVL-030, TC-OVL-031 | +| FR-9 (coexistence with banner) | TC-OVL-032, TC-OVL-033, TC-OVL-034, TC-OVL-035 | +| FR-10 (concurrent operations) | TC-OVL-036, TC-OVL-037, TC-OVL-038, TC-OVL-039, TC-OVL-040 | +| NFR-1 (no frame blocking) | TC-OVL-004, TC-OVL-049 | +| NFR-2 (i18n-ready strings) | TC-OVL-014 (counter format), TC-OVL-020 (description), TC-OVL-013 (elapsed) | +| NFR-3 (accessibility) | TC-OVL-041, TC-OVL-042, TC-OVL-043, TC-OVL-044 | +| NFR-4 (theme) | TC-OVL-046 | +| NFR-5 (log-once) | TC-OVL-045, TC-OVL-040 | +| NFR-6 (cheap idle) | TC-OVL-001 | +| R-1 (z-order vs secret-prompt) | TC-OVL-048 | +| R-2 (concurrent model) | TC-OVL-036 to TC-OVL-040 [depends on 1d] | +| R-3 (Cancel honesty) | TC-OVL-023 (no button = pure block), TC-OVL-024 (Cancel only when cancelable) | +| R-4 (stuck overlay safety valve) | TC-OVL-047 [partial — BLOCKED pending 1d] | +| R-7 (kittest coverage checklist) | TC-OVL-028 (input blocked), TC-OVL-042/043 (Esc), TC-OVL-015–017 (counter validation), TC-OVL-024–026 (action id FIFO), TC-OVL-045 (log-once), TC-OVL-034/035 (dismiss+banner hand-off) | + +--- + +## 4. Open Items for Nagatha (Phase 1d) + +The following items require architecture decisions before the marked TCs can be finalized or +implemented: + +| Item | Blocks | Required decision | +|---|---|---| +| **Concurrent overlay model** (stack vs. replace vs. reject) | TC-OVL-036 to TC-OVL-040 | Confirm stack model (AC-10.1) or choose an alternative. If not stack, TC-OVL-036–040 must be rewritten to match the chosen semantics. | +| **Stuck-overlay threshold** | TC-OVL-047 | Define the threshold duration after which the elapsed readout auto-activates and the escape hatch appears. Define which operations get an escape hatch. | +| **Cancel semantics** (R-3) | TC-OVL-024, TC-OVL-042 | Confirm that the BackendTask system supports cooperative cancellation for the operations that will carry a Cancel button. Without this, TC-OVL-024 and TC-OVL-042 verify the UI-only action queue but the end-to-end cancel path is untestable. | +| **Escape hatch button design** | TC-OVL-047 | Is the escape hatch a third button, a replacement for Cancel, or an entirely different mechanism? | + +--- + +## 5. Notes on Test Executability + +### Runnable as kittest (CI-safe) +TC-OVL-001, TC-OVL-002, TC-OVL-003, TC-OVL-005, TC-OVL-006, TC-OVL-007, TC-OVL-008, +TC-OVL-009, TC-OVL-011, TC-OVL-012, TC-OVL-013, TC-OVL-014, TC-OVL-015, TC-OVL-016, +TC-OVL-017, TC-OVL-018, TC-OVL-019, TC-OVL-020, TC-OVL-021, TC-OVL-022, TC-OVL-023, +TC-OVL-024, TC-OVL-025, TC-OVL-026, TC-OVL-027, TC-OVL-028, TC-OVL-029, TC-OVL-030, +TC-OVL-033, TC-OVL-036, TC-OVL-037, TC-OVL-038, TC-OVL-039, TC-OVL-041, TC-OVL-042, +TC-OVL-043, TC-OVL-044, TC-OVL-046. + +### Require integration harness (AppState-level) +TC-OVL-010, TC-OVL-034, TC-OVL-035, TC-OVL-048. + +### Design-review only (not automatable as unit/kittest) +TC-OVL-004 (no frame blocking), TC-OVL-014 (i18n string format — fragment check), +TC-OVL-020 (single-label assertion), TC-OVL-027 (no bare `ui.button()`), +TC-OVL-029 (no `set_enabled`), TC-OVL-031 (render seam placement), TC-OVL-032 (z-order), +TC-OVL-040 (log-once concurrent), TC-OVL-045 (log-once general), TC-OVL-046 (no hardcoded +colors), TC-OVL-047 (partial), TC-OVL-048 (render order), TC-OVL-049 (no async in render +path). + +--- + +*Brain the size of a planet, and here I am specifying acceptance criteria for a spinner. +At least the spinner is honest about not knowing how long things take. More than can be said +for most documentation.* From ce985e126ad5d3418bde13af9e2283c876442e28 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:05:47 +0200 Subject: [PATCH 03/22] docs(overlay): development plan and architecture decisions Co-Authored-By: Claude Opus 4.8 --- .../03-dev-plan.md | 513 ++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md new file mode 100644 index 000000000..da827cce7 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -0,0 +1,513 @@ +# Blocking Progress Overlay — Development Plan & Architecture Decisions + +**Phase:** 1d (Architecture) — final architecture call + development plan +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-17 +**Inputs:** `01-requirements-ux.md` (Diziet), `02-test-spec.md` (Marvin) +**Sibling reference:** `MessageBanner` (`src/ui/components/message_banner.rs`) + +--- + +## 0. Reading of the Situation + +The requirements are sound and the test spec is thorough. My task is to remove the five +ambiguities the downstream phases deferred, place the component precisely, fix the public +surface so Marvin's 49 cases compile against it unchanged, and hand Bilby an ordered build. + +I investigated the live tree rather than trusting the summaries. Two findings move the +architecture: + +1. **The backend has no per-operation cancellation.** `handle_backend_task` + (`src/app.rs:750-762`) dispatches through `tokio::task::spawn_blocking` + `handle.block_on` + and **discards the `JoinHandle`**. The only `CancellationToken` in the app is the global + shutdown token in `TaskManager` (`src/utils/tasks.rs:10`, used by `shutdown_inner` at + `:116-193`), which is not threaded into `run_backend_task` (`src/backend_task/mod.rs:490`). + A Cancel button today can only *stop waiting* — never abort. This is decisive for R-3. + +2. **The live secret-prompt modal is an `egui::Window` (Order::Middle) over a Background dim** + (`src/ui/components/passphrase_modal.rs:128-156`), rendered at AppState level via + `render_secret_prompt` (`src/app.rs:1151,1527`) — *after* the visible screen's `ui()`. That + reality dictates the overlay's layer and call-site ordering (R-1), which differs slightly + from the Foreground assumption in the test spec's TC-OVL-048. + +Everything else follows. + +--- + +## 1. Architecture Decisions + +### D-1 — New standalone component (confirms Diziet §7) ✅ + +**Decision:** Build a **new component** `ProgressOverlay` in `src/ui/components/progress_overlay.rs` +that *mirrors* `MessageBanner`'s patterns. Do **not** extend `MessageBanner`. + +**Rationale:** The two have opposite z-order (banner renders *inside* `island_central_panel` +at `src/ui/components/styled.rs:565`, Background order; overlay must cover the top/left panels +too), opposite blocking semantics, different multiplicity (banner: up to 5 visible +simultaneously, `message_banner.rs:12`; overlay: one rendered), and different lifecycle (banner +auto-dismisses by severity, `message_banner.rs:580-585`; overlay never times out). Folding +spinner/step/button-set/blocking fields into `BannerState` (`message_banner.rs:71-96`) would +bloat the 99 % of banners that are simple notices and entangle the central render path — +a single-responsibility violation. The *patterns* are proven and reused; the *type* is its own. + +What is mirrored, not merged: +- Global state in egui `ctx.data` temp storage keyed by a dedicated id (banner uses + `BANNER_STATE_ID`, `message_banner.rs:13` + `get_banners`/`set_banners` at `:808-821`). +- A lifecycle handle holding `{ ctx: egui::Context, key: u64 }` with `&self` builder methods + returning `Option<&Self>` and a consuming `clear(self)` (banner: `BannerHandle`, `:155-290`). +- An action-id queue drained by the app loop (banner: `BANNER_ACTIONS_ID` + + `push_action`/`take_action`, `:14-19,517-525,824-844`). +- Log-once via a `logged` flag (banner: `BannerState.logged`, `:95,620-624`). +- Theme tokens + button helpers (`DashColors`, `ComponentStyles`). +- Monotonic key counter (`AtomicU64`, banner `:24-31`). + +### D-2 — Focused copy of `ctx.data` plumbing, no shared base (confirms Diziet's lean) ✅ + +**Decision:** The overlay defines its **own** private `get_overlay_state`/`set_overlay_state` +and `get_overlay_actions`/`set_overlay_actions`, each ~6 lines, exactly mirroring the banner's +private helpers. **No shared plumbing module.** + +**Rationale:** The "shared" surface is already egui's own API (`ctx.data` / +`get_temp`/`insert_temp`/`remove`). The only project-specific convention on top is +"remove the slot when the collection is empty" (banner `set_banners`, `:815-821`) — two lines. +Extracting a generic `TempSlot` would couple two intentionally-divergent widgets and add +indirection for negative value. The element types even differ (`Vec` vs +`Vec`; banner actions `Vec` vs overlay actions `Vec` but with a +distinct id). Premature abstraction is rejected; a focused copy reads cleaner. (If a third +`ctx.data`-backed global widget ever appears, revisit — rule of three, not two.) + +### D-3 — Concurrent model: STACK, keyed by handle (confirms Diziet AC-10.1) ✅ + +**Decision:** A **stack** of active requests (`Vec`), visible while non-empty; +the **last-pushed (topmost)** entry is rendered; each handle dismisses **only its own** key; +the overlay clears only when the stack empties. A concurrent push logs **once**. + +**Rationale:** Because the overlay blocks the UI, a human cannot launch a second blocker — +concurrency can only arise programmatically (a cold-start migration firing while a network +switch is up; `run_backend_tasks_concurrent` at `backend_task/mod.rs:472`). Under +last-writer-replace or reject-second, the first task to finish would `clear()` the shared slot +and **unblock the UI while the other operation is still running** — the precise hazard the +overlay exists to prevent. The stack is the only model that never strands a running operation +behind a prematurely-cleared overlay. Cost over `Option` is trivial — it is the same +`Vec` shape the banner already uses. **Marvin's Group K (TC-OVL-036…040) stands as +written; no rewrite required.** + +### D-4 — Stuck-overlay threshold: 30 s, informational reveal; escape-hatch deferred ⏸ + +**Decision:** +- Define `STUCK_OVERLAY_THRESHOLD = Duration::from_secs(30)`. +- After 30 s on the topmost request (tracked by `created_at`, mirroring `BannerState.created_at`), + `render_global` **auto-reveals** (a) the honest elapsed readout `Elapsed: {seconds}s` (even if + not explicitly enabled) and (b) a calm reassurance line: *"This is taking longer than usual."* + Both are **visual only** — no fake progress, no auto-abort. +- **No automatic escape-hatch button in v1.** An escape hatch that lowered the overlay would + unblock the UI while a state-changing operation (broadcast, migration) still ran — unsafe, and + impossible to make safe without D-5's backend cancellation. The escape hatch is therefore + **deferred** and bundled with the cancellation follow-up (T7). + +**Rationale:** 30 s is past a normal Platform round-trip ("up to a minute" per J-1) yet early +enough to reassure rather than alarm. The reveal is benign and honest. The escape hatch is the +same honesty problem as Cancel (D-5) and waits on the same enabling work. + +**Guidance to Marvin:** TC-OVL-047 is **partially unblocked**. Assert the *informational* +behavior: after simulated 30 s, `Elapsed: {seconds}s` and the reassurance label appear. Mark the +**escape-hatch button** portion **deferred (tracked with T7)**, not BLOCKED. + +### D-5 — Cancel semantics: button-less block is the default; Cancel API ships but is unwired ⏸ + +**Finding (decisive):** The `BackendTask` system supports **no cooperative cancellation** +(see §0.1). `handle_backend_task` discards the abort handle; `run_backend_task` takes no cancel +token; the operation runs inside `block_on` on a blocking thread. + +**Decision:** +- The overlay **ships the full Cancel/action-id API** (`with_cancel`, `with_action`, + `take_actions`, `OVERLAY_CANCEL_ACTION_ID`) — it is UI-only, mirrors the banner, and is fully + unit/kittest-testable (TC-OVL-024/025/026/042 verify the *enqueue* path). +- **The architectural default for every production caller is a button-less block.** No + production overlay may attach Cancel/actions to a `BackendTask`-backed operation until real + cancellation lands (T7). This keeps the button honest (FR-7 AC-7.5, R-3): we never paint a + Cancel that lies. +- `AppState::drain_overlay_actions` is wired for completeness; for `OVERLAY_CANCEL_ACTION_ID` + it logs *"cancel requested but per-operation cancellation is not yet supported"* and does + **not** lower the overlay (lowering mid-op is the unsafe behavior). In v1 nothing enqueues it. + +**Guidance to Marvin:** TC-OVL-024 and TC-OVL-042 remain valid as **UI-queue** tests (click/Esc +→ action id enqueued). The **end-to-end abort** half is untestable until T7; note it as such. + +--- + +## 2. Module Placement (DET policy) + +| Artifact | Location | Why | +|---|---|---| +| `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `OverlayState`, `OverlayButton`, all `ctx.data` plumbing, `render_global` | `src/ui/components/progress_overlay.rs` (**new**) | It renders egui → it is a Component (DET policy: "rendering widgets → `ui/components/`"). State lives in global `ctx.data`, not screen-owned, so **no `ui/state/` split** — same as `MessageBanner`. | +| `step_is_renderable(current, total) -> bool` | private free fn in `progress_overlay.rs` | Pure display-gating (hide nonsense pairs, FR-5 AC-5.2). Not a domain validator with external callers, so it stays inline (unit-testable in-file). If a second caller ever needs it, promote to `model/`. | +| `OptionOverlayExt` (handle lifecycle ext) | `progress_overlay.rs` | Mirrors `OptionBannerExt` (`message_banner.rs:915-955`). | +| `render_global` call site + `drain_overlay_actions` | `src/app.rs` (`AppState::update`) | AppState-level render seam (§3). | +| kittest suite | `tests/kittest/progress_overlay.rs` (**new**) + `mod progress_overlay;` in `tests/kittest/main.rs` | Mirrors `tests/kittest/message_banner.rs`. | +| `mod progress_overlay;` + re-exports | `src/ui/components/mod.rs` | Export `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `OVERLAY_CANCEL_ACTION_ID`, `OptionOverlayExt` (mirror banner re-exports). | + +**No new crates.** Everything reuses what is already a dependency: `egui::Spinner` +(idiom already used at `src/ui/wallets/shielded_tab.rs:285` etc.), `ctx.data`, `DashColors`, +`ComponentStyles`, and — for T7 only — `tokio_util::sync::CancellationToken` (already in tree, +`utils/tasks.rs:3`). egui pinned at `0.33.3` (`Cargo.toml`). + +--- + +## 3. Public API Design + +Names are aligned **exactly** to Marvin's assumed surface (test-spec §1.2) so the 49 cases +compile unchanged. Signatures mirror `BannerHandle`/`MessageBanner`. + +```rust +// src/ui/components/progress_overlay.rs + +use std::time::{Duration, Instant}; +use std::sync::atomic::{AtomicU64, Ordering}; + +const OVERLAY_STATE_ID: &str = "__global_progress_overlay"; +const OVERLAY_ACTIONS_ID: &str = "__global_progress_overlay_actions"; + +/// After this long on the topmost request, reveal the honest elapsed readout +/// and a reassurance line (D-4). Visual only — never auto-aborts. +const STUCK_OVERLAY_THRESHOLD: Duration = Duration::from_secs(30); + +/// Well-known action id for the canonical Cancel control (D-5). +pub const OVERLAY_CANCEL_ACTION_ID: &str = "overlay.cancel"; + +static OVERLAY_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// One active blocking request. The stack (D-3) holds a `Vec`; +/// the last element is rendered. +#[derive(Clone)] +struct OverlayState { + key: u64, + description: Option, + /// Raw, unvalidated; render gates on `step_is_renderable`. + step: Option<(u32, u32)>, + /// Cancel is just a button carrying `OVERLAY_CANCEL_ACTION_ID`. + buttons: Vec, + /// Explicit opt-in; also force-true once `created_at` passes the threshold. + show_elapsed: bool, + created_at: Instant, + /// Log-once on show (NFR-5). + logged: bool, + /// Log-once on content change; mirrors the banner's single `logged` flag + /// but keyed on content so description/step updates log exactly once. + logged_content: Option<(Option, Option<(u32, u32)>)>, +} + +#[derive(Clone)] +struct OverlayButton { + /// i18n unit. Empty → renderer supplies the localized "Cancel". + label: String, + action_id: String, + /// primary → right side, DASH_BLUE; secondary/Cancel → left side. + is_primary: bool, +} + +/// Builder/config for `show_global`. `OverlayConfig::default()` == the test +/// spec's `config_default()` (spinner only, no counter, no buttons, elapsed off). +#[derive(Clone, Default)] +pub struct OverlayConfig { /* description, step, show_elapsed, buttons */ } + +impl OverlayConfig { + pub fn new() -> Self; + pub fn with_description(self, text: impl std::fmt::Display) -> Self; + pub fn with_step(self, current: u32, total: u32) -> Self; + pub fn with_elapsed(self) -> Self; // honest count-up + pub fn with_cancel(self, action_id: impl std::fmt::Display) -> Self; + pub fn with_action(self, label: impl std::fmt::Display, + action_id: impl std::fmt::Display) -> Self; // generic = primary +} + +/// Lifecycle handle. `'static`, `Clone`, safe to store on a screen +/// (mirrors `BannerHandle`; same INTENTIONAL(SEC-004) Send+Sync note). +#[derive(Clone)] +pub struct OverlayHandle { ctx: egui::Context, key: u64 } + +impl OverlayHandle { + pub fn is_active(&self) -> bool; // key still on stack + pub fn set_description(&self, text: impl std::fmt::Display) -> Option<&Self>; + pub fn set_step(&self, current: u32, total: u32) -> Option<&Self>; + pub fn clear_step(&self) -> Option<&Self>; + pub fn with_cancel(&self, action_id: impl std::fmt::Display) -> Option<&Self>; + pub fn with_action(&self, label: impl std::fmt::Display, + action_id: impl std::fmt::Display) -> Option<&Self>; + pub fn elapsed(&self) -> Option; + pub fn clear(self); // dismiss own entry only +} + +pub struct ProgressOverlay; + +impl ProgressOverlay { + /// Push a request; returns its handle. Non-blocking; only writes `ctx.data`. + pub fn show_global(ctx: &egui::Context, + description: impl std::fmt::Display, + config: OverlayConfig) -> OverlayHandle; + pub fn show_global_spinner_only(ctx: &egui::Context) -> OverlayHandle; + pub fn has_global(ctx: &egui::Context) -> bool; // cheap one-slot read + /// Called once per frame from `AppState::update` (§4). Renders the topmost + /// entry; no-op early-out when the stack is empty (NFR-6). + pub fn render_global(ctx: &egui::Context); + /// Drains the action-id queue FIFO (TC-OVL-026 expects a drained Vec). + pub fn take_actions(ctx: &egui::Context) -> Vec; + /// Clear every entry — used on network switch alongside the banner reset. + pub fn clear_all_global(ctx: &egui::Context); +} + +/// Mirror of `OptionBannerExt` for `Option` screen fields. +pub trait OptionOverlayExt { + fn take_and_clear(&mut self); + fn replace(&mut self, ctx: &egui::Context, + description: impl std::fmt::Display, config: OverlayConfig); +} +impl OptionOverlayExt for Option { /* … */ } +``` + +**Stack/key semantics (D-3):** `show_global` allocates a key via `OVERLAY_KEY_COUNTER`, pushes +an `OverlayState`, and (when the stack was already non-empty) logs the concurrency exactly once. +Handle methods `find` by key and return `None` on a missing key (stale handle → no-op, never +panic; TC-OVL-007/009). `clear(self)` `retain`s out its own key (TC-OVL-037/038). +`render_global` renders `stack.last()`. + +**Ownership pattern (FR-9 hand-off):** A dispatching screen stores +`op_overlay: Option` exactly as screens store `refresh_banner: Option` +today. It raises the overlay when it returns the `BackendTask`, and lowers it in +`display_task_result` via `self.op_overlay.take_and_clear()` **before** AppState shows the +result banner — giving the single-frame, temporally-exclusive hand-off of AC-9.3. App-level ops +already in AppState (e.g. the network switch, which holds `network_switch_banner` at +`app.rs:821`) get a parallel `network_switch_overlay: Option` — that wiring is a +follow-up, not the component (T4 documents it; per-feature adoption is out of scope here). + +--- + +## 4. egui Integration + +### 4.1 Call site (`AppState::update`, `src/app.rs`) + +Insert the overlay render between the visible screen's `ui()` and the secret prompt, and drain +its actions next to the banner drain: + +```rust +// … existing, app.rs:1523-1527 … +actions.push(self.visible_screen_mut().ui(ctx)); + +ProgressOverlay::render_global(ctx); // NEW — above banners, below secret prompt (R-1) +self.render_secret_prompt(ctx); // unchanged — stays ABOVE overlay (app.rs:1527) + +// … app.rs:1539-1540 … +self.handle_banner_esc(ctx); +self.drain_banner_actions(ctx); +self.drain_overlay_actions(ctx); // NEW — maps OVERLAY_CANCEL_ACTION_ID per D-5 +``` + +On network switch, clear the overlay alongside banners (mirror `MessageBanner::clear_all_global`). + +### 4.2 Layer ordering (R-1) — the decisive part + +egui paints, within one `Order`, in area-creation order; an interacted/focused area is raised +to the top of its order. The live secret prompt is an `egui::Window` (default `Order::Middle`) +that `request_focus()`es its input (`passphrase_modal.rs:139-172`). + +``` + Order::Middle, created LAST, focus-raised → secret-prompt / confirmation modal (R-1: top) + Order::Middle, created in render_global → BLOCKING OVERLAY (dim + sink + card) + Order::Background (CentralPanel content) → MessageBanner banners (styled.rs:565) + Order::Background (TopBottomPanel/SidePanel) → top panel / left nav / central content +``` + +- Overlay on **`Order::Middle`** sits above all Background panels and banners → covers them + (FR-8 AC-8.3, FR-9 AC-9.1). Banner state persists in `ctx.data`, so banners reappear intact on + dismiss (FR-9 AC-9.2). +- Secret prompt is also `Order::Middle` but **created after** `render_global` and focus-raised → + stays above the overlay and remains interactive (R-1, TC-OVL-048). This is exactly the + call-order invariant TC-OVL-048 asserts; we lock it with the design-review test. (Confirmation + dialogs are *pre-dispatch*, never concurrent with a blocker, so they need no special handling.) + +### 4.3 Dim plane + input-blocking technique (FR-8, NFR-1) + +`Ui::set_enabled(false)` is **deprecated in egui 0.33** (confirmed memory; test-spec AC-8.2). +Use a top input-capturing layer instead — the same shape the passphrase modal already uses +(`layer_painter` + a centered window), extended with a full-window interactable sink: + +1. **Dim:** `ctx.layer_painter(LayerId::new(Order::Middle, Id::new("__overlay_dim")))` + `.rect_filled(ctx.content_rect(), 0.0, DashColors::modal_overlay())` — re-read each frame so a + theme switch mid-overlay is correct (NFR-4; `modal_overlay()` = `rgba(0,0,0,120)`, + `theme.rs:430`). `content_rect()` is the same viewport rect the modal uses + (`passphrase_modal.rs:128`). +2. **Pointer sink:** an `egui::Area::new(Id::new("__overlay_sink")).order(Order::Middle) + .fixed_pos(rect.min)` that `ui.allocate_response(rect.size(), Sense::click_and_drag())`. Being + the topmost interactable at every point below the card, it consumes pointer events so + Background widgets never receive them (TC-OVL-028). Its own clicks are ignored → backdrop click + does **not** dismiss (FR-8 AC-8.4, TC-OVL-030). +3. **Keyboard:** while the overlay is up, consume the navigation/confirm keys so they never reach + widgets beneath (TC-OVL-029/041): + - **Esc:** if the topmost entry has a Cancel button → enqueue its action id and consume + (TC-OVL-042); otherwise consume-and-swallow (TC-OVL-043). Never dismisses a non-cancelable + block. + - **Enter:** never activates Cancel (TC-OVL-044). If a single primary action exists, Enter MAY + activate it (AC-3c). + - **Tab:** consumed at the overlay layer (focus trap, TC-OVL-041); focus is requested onto the + first overlay button on raise so it cannot escape beneath. Implemented via + `ctx.input_mut(|i| i.events.retain(...))` filtering Tab/Esc/Enter while active — scoped to the + overlay-active branch so global shortcuts are untouched when idle. +4. **Card:** `egui::Area::new(Id::new("__overlay_card")).order(Order::Middle) + .anchor(Align2::CENTER_CENTER, Vec2::ZERO)` → `egui::Frame` (fill `surface`/`window_fill`, + `Shadow::elevated()`, `RADIUS_LG`) with a min width and a vertical layout. Long descriptions + wrap and, if taller than a cap, scroll inside the card (`ScrollArea`) so the card never pushes + off-screen (FR-6 AC-6.2, TC-OVL-021). + +**NFR-1 / PR860:** every path here is synchronous `ctx.data` + painting — no `.await`, no +`block_on`, no `sleep`. The operation runs on its tokio `BackendTask`; the overlay is raised at +dispatch and lowered when the `TaskResult` is polled in `update()` (`app.rs:1290`). TC-OVL-049 +locks this by inspection. + +### 4.4 Theme tokens used (NFR-4, zero hardcoded colors) + +`DashColors::modal_overlay()` (dim), `DashColors::surface`/`ctx.style().visuals.window_fill` +(card), `DashColors::text_primary`/`text_secondary` (description/elapsed), +`DashColors::DASH_BLUE` (spinner), `ComponentStyles::add_secondary_button` (Cancel, left) + +`add_primary_button` (primary, right), `Shape::RADIUS_LG`, `Shadow::elevated()`. All re-evaluated +per frame from `dark_mode` (TC-OVL-046). + +--- + +## 5. Progress Model + +- **Spinner (FR-4):** `egui::Spinner::new().color(DashColors::DASH_BLUE)` — the repo idiom + (`shielded_tab.rs:285`). `Spinner` self-requests repaint via egui's animation clock; **no + custom per-frame timer** (AC-4.1). Always rendered while the overlay is active, in every config + (TC-OVL-011). Never a `ProgressBar` (TC-OVL-012/018). +- **Step counter (FR-5):** single i18n unit `"Step {current} of {total}"` — one `ui.label`, no + fragment concatenation (NFR-2, TC-OVL-014). Rendered only when + `step_is_renderable(current, total)` — i.e. `current >= 1 && total >= 1 && current <= total`; + otherwise the line is omitted entirely, reserving no space (`(0,0)`, `(4,3)`, `(0,5)` all hide + it; TC-OVL-015/016/017/019). Independent of the spinner — presence never makes it determinate + (AC-5.3). +- **Elapsed (FR-4 AC-4.3):** off by default (TC-OVL-013-A). When enabled via `with_elapsed`, or + auto-revealed after `STUCK_OVERLAY_THRESHOLD` (D-4), render `"Elapsed: {seconds}s"` from + `created_at.elapsed().as_secs()` — counts **up**, never down, never a percentage (TC-OVL-013-B). + When elapsed or the threshold reveal is live, `render_global` calls + `ctx.request_repaint_after(Duration::from_secs(1))` (mirroring `process_banner`, + `message_banner.rs:639-641`) so the second ticks. +- **Description (FR-6):** optional full sentence, one wrapped `ui.label` (TC-OVL-020). + +--- + +## 6. Interaction-Blocking Strategy (FR-8 + NFR-1) + +Restated as the invariant Bilby must preserve: **the overlay blocks *visually and by input +routing*, never by stalling the frame thread.** The dim + sink + card all live on `Order::Middle` +above the panels; the sink consumes pointer events and the input filter consumes Tab/Esc/Enter, +so nothing beneath reacts — without touching `set_enabled`. The blocked work proceeds on its +`BackendTask`; the overlay is pure presentation over global `ctx.data`. There is no synchronous +wait anywhere in the show or render path (NFR-1, PR860). + +--- + +## 7. Task Breakdown + +Ordered. Each task is independently reviewable; TC references tie to Marvin's spec. T1→T5 are the +component; T6 is tests; T7 is the deferred backend enabler. + +### T1 — State, `ctx.data` plumbing, handle + stack (no rendering) (~250 LOC) +Define `OverlayState`, `OverlayButton`, `OverlayConfig` (+ builders), `OverlayHandle`, +`OVERLAY_KEY_COUNTER`, `OVERLAY_CANCEL_ACTION_ID`, `STUCK_OVERLAY_THRESHOLD`. Implement +`get/set_overlay_state`, `get/set/push_overlay_action`, `take_actions`; `show_global`, +`show_global_spinner_only`, `has_global`, `clear_all_global`; all handle methods with stack +semantics (push on show, `retain` own key on clear, topmost lookup), log-once flags, and +`step_is_renderable`. Inline unit tests for stack push/dismiss, FIFO queue, `step_is_renderable`. +**Satisfies (ctx.data-level):** TC-OVL-003, 007, 009, 023(empty queue), 036, 037, 038, 040, 045, +and the `has_global` early-out for NFR-6. + +### T2 — `render_global` rendering (~300 LOC) +Idle early-out; dim plane; centered card; `egui::Spinner` (DASH_BLUE); validated step line; +wrapped/scrolling description; elapsed + reassurance (conditional, incl. threshold reveal); +button row (Cancel left via `add_secondary_button`, primary right via `add_primary_button`) with +clicks pushing action ids; theme tokens; log-once; 1 s repaint when elapsed/threshold live. +**Satisfies:** TC-OVL-001, 002, 005, 006, 008, 011, 012, 013, 014, 015, 016, 017, 018, 019, 020, +021, 022, 027, plus the visual half of 024/025 and the topmost-render of 036/039. + +### T3 — Input blocking + keyboard semantics (~120 LOC, within `render_global`) +Full-window pointer sink (`Sense::click_and_drag`); backdrop click ignored; Tab/Esc/Enter +consumed via `ctx.input_mut` event filtering; Esc→cancel-if-cancelable / swallow-otherwise; +Enter never cancels; focus requested onto first overlay button on raise. +**Satisfies:** TC-OVL-028, 029, 030, 041, 042, 043, 044. **Design-review:** TC-OVL-049 (no async), +and "no `set_enabled`, no bare `ui.button()`" for TC-OVL-027/029. + +### T4 — AppState integration + ownership ergonomics (~120 LOC + small edits) +Add `ProgressOverlay::render_global(ctx)` before `render_secret_prompt` in `update()`; add +`drain_overlay_actions` (D-5 policy: log unsupported cancel, do not lower); clear overlay on +network switch; implement `OptionOverlayExt`; document the `op_overlay: Option` +screen field convention and the AC-9.3 hand-off. Export from `ui/components/mod.rs`. +**Satisfies (integration):** TC-OVL-010, 031, 032, 034, 035, 048. + +### T5 — Stuck-overlay threshold behavior (~40 LOC, folded into `render_global`; separate for traceability) +Wire `STUCK_OVERLAY_THRESHOLD`: once `created_at.elapsed() >= 30s`, force `show_elapsed` and +render the reassurance line; ensure the 1 s repaint is active so the reveal actually fires. +**Satisfies:** TC-OVL-047 (informational portion). Escape-hatch button → T7. + +### T6 — kittest suite `tests/kittest/progress_overlay.rs` (+ register in `main.rs`) (~400 LOC) +Mirror `tests/kittest/message_banner.rs` (Harness + `query_by_label` + `ctx.data` reads). +Implement every kittest-tagged case from test-spec §5: TC-OVL-001, 002, 003, 005-009, 011-022, +023-030, 033, 036-039, 041-044, 046. Encode design-review invariants (049, 045, 040, 031, 032, +048, 027) as comments/asserts where assertable. **Run `cargo +nightly fmt` and +`cargo clippy --all-features --all-targets -- -D warnings`.** + +### T7 — (DEFERRED enabler) Backend cooperative cancellation + Cancel/escape-hatch wiring +Thread a per-operation `CancellationToken` (already a dep, `utils/tasks.rs:3`) into +`run_backend_task`; retain the abort handle in `handle_backend_task` (`app.rs:750`); `tokio::select!` +the work against the token. Then enable real Cancel buttons + the threshold escape-hatch and wire +`drain_overlay_actions` to abort. **Unblocks:** the end-to-end half of TC-OVL-024/042 and the +escape-hatch portion of TC-OVL-047. **Out of scope for this feature's v1.** Marked here with a +TODO so the gap is tracked, not lost. + +--- + +## 8. Risks & Sequencing (for Bilby) + +1. **Layer ordering relies on call order** (overlay `render_global` *before* `render_secret_prompt`, + both `Order::Middle`; secret prompt focus-raised). Lock with TC-OVL-048 (design-review on call + order + integration on prompt interactivity). If a future modal is *not* focus-raised, it could + fall behind the overlay — document the invariant at the call site. +2. **egui 0.33 input consumption.** Filtering Tab/Esc/Enter via `ctx.input_mut` must be scoped to + the overlay-active branch; verify global shortcuts (and the existing `handle_banner_esc`, + `app.rs:1089`) still behave when no overlay is up. The banner Esc handler and overlay Esc handler + must not both fire — overlay consumes Esc first (it renders earlier in the frame). +3. **No backend cancellation (D-5).** Enforce by review: no production `show_global` call may pass + `with_cancel`/`with_action` until T7. Consider a clippy-grep gate in CI. +4. **Repaint discipline.** `request_repaint_after(1s)` only when elapsed/threshold is live; the + `Spinner` already self-repaints. Do not unconditionally wake an idle UI. +5. **Sink vs. secret prompt.** The Middle-order sink must not eat the secret prompt's input. The + prompt, created later and focus-raised, is above the sink — verified by TC-OVL-048's integration + check; treat any regression there as release-blocking. +6. **Build order:** T1 → T2 → T3 → T5 → T4 → T6. T1-T3+T5 produce a self-contained, unit-tested + component; T4 wires it into the app; T6 is the kittest gate. T7 is independent and later. + +--- + +## 9. Decision Summary + +| # | Decision | Status | +|---|---|---| +| D-1 | New `ProgressOverlay` component mirroring `MessageBanner`; do not extend the banner | Confirmed | +| D-2 | Focused copy of `ctx.data` plumbing; no shared base module | Confirmed | +| D-3 | Concurrent model = **stack** keyed by handle (Group K stands) | Confirmed | +| D-4 | Stuck threshold = **30 s**, informational reveal; escape-hatch deferred to T7 | Decided | +| D-5 | Backend has **no** cancellation → button-less block default; Cancel API ships unwired | Decided | + +--- + +## 10. Candy Tally + +Confirmed architecture findings / decisions surfaced in this plan: + +| Severity | Count | Items | +|---|---|---| +| **High** | 2 | D-5 backend-cancellation gap (Cancel would lie); R-1 layer-order invariant (overlay vs secret prompt) | +| **Medium** | 3 | D-3 stack required (else UI unblocks mid-op); D-4 threshold/escape-hatch safety; D-1 single-responsibility (no banner bloat) | +| **Low** | 2 | D-2 no premature shared abstraction; `set_enabled` deprecation → top-layer input sink | + +**Total: 7 findings.** Seven candies — a respectable haul for a spinner that, to its credit, +never pretends to know how long anything will take. From 112c9f8409b5afe7aaa7b42cec5ae30646c66a54 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:48:45 +0200 Subject: [PATCH 04/22] feat(overlay): generic button facility + Component trait conformance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folds in two user-mandated redesigns of the blocking progress overlay that the prior session did not land: Redirect 1 — generic button facility (no first-class Cancel). The overlay knows nothing about cancellation. `OVERLAY_CANCEL_ACTION_ID`, `with_cancel`, `CANCEL_LABEL`, and the Esc->Cancel routing are gone. A caller attaches a generic button via `OverlayConfig::with_button(id, label)` / `OverlayHandle::with_button(id, label)`, choosing its own opaque action id and label. A click enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants — including its own cancellation. Esc/Tab/Enter are swallowed so a hard block is never keyboard-dismissable. Redirect 2 — `Component` trait conformance (placement legitimacy for `src/ui/components/`). `ProgressOverlay` is now a struct holding `state: Option`; `Component::show` renders that instance's card and returns `ProgressOverlayResponse` (`DomainType = String`, the clicked action id), with `current_value()` reporting the last clicked id. The global `render_global` path is preserved as the production entry point; the instance `show()` is additive, mirroring `MessageBanner`. Also: clamp the card to the window so it never runs off-screen in a narrow window (FR-6); settle the centered card in the kittest click/focus cases before interacting (anchored CENTER_CENTER needs a few frames to cache its size). Docs: dev-plan gains a post-outage note superseding D-5/FR-7; test-spec reframes the Cancel-specific cases to the generic-button model. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../02-test-spec.md | 110 +- .../03-dev-plan.md | 27 + src/app.rs | 29 +- src/ui/components/mod.rs | 4 + src/ui/components/progress_overlay.rs | 997 ++++++++++++++++++ tests/kittest/main.rs | 1 + tests/kittest/progress_overlay.rs | 910 ++++++++++++++++ 7 files changed, 2027 insertions(+), 51 deletions(-) create mode 100644 src/ui/components/progress_overlay.rs create mode 100644 tests/kittest/progress_overlay.rs diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md index 03dfbb5fc..0afd815d5 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -19,6 +19,13 @@ Every FR (FR-1 through FR-10) and NFR (NFR-1 through NFR-6) is covered by at lea Items that depend on the architecture decision deferred to Nagatha (1d) are marked **[depends on 1d]**. +> **Post-outage reframe (generic button).** First-class "Cancel" was removed in favour of a +> generic button facility: a caller attaches a button with `with_button(id, label)`, the click +> enqueues the caller's id, the owning screen drains it via `take_actions` and runs its own logic +> (including cancellation), and Esc does **not** dismiss. The Cancel-specific cases below +> (TC-OVL-024/025/026/027/042/043/044) are **reframed in place** to the generic-button model — +> numbers are preserved; each carries a "(reframed post-outage: generic button)" note. + ### 1.1 Test Type Key | Tag | Meaning | @@ -43,11 +50,9 @@ illustrative; the architecture phase (1d) may adjust them. | `OverlayHandle::set_description(text)` | Updates description; returns `Option<&Self>` | | `OverlayHandle::set_step(current, total)` | Updates counter; returns `Option<&Self>` | | `OverlayHandle::clear_step()` | Removes counter; returns `Option<&Self>` | -| `OverlayHandle::with_cancel(action_id)` | Adds Cancel button; returns `Option<&Self>` | -| `OverlayHandle::with_action(label, action_id)` | Adds generic button; returns `Option<&Self>` | +| `OverlayConfig::with_button(id, label)` / `OverlayHandle::with_button(id, label)` | Adds a generic button (reframed post-outage: no built-in Cancel); the handle form returns `Option<&Self>` | | `OverlayHandle::clear()` | Dismisses this handle's overlay entry | | `OverlayHandle::is_active()` | Returns `true` if still on the overlay stack | -| `OVERLAY_CANCEL_ACTION_ID` | Well-known constant for the Cancel action id | --- @@ -188,7 +193,7 @@ loop without any risk of yielding. 2. Call `handle.clear()` to dismiss. 3. Call `handle.set_description("After clear")`. 4. Call `handle.set_step(1, 3)`. -5. Call `handle.with_cancel(OVERLAY_CANCEL_ACTION_ID)`. +5. Call `handle.with_button("overlay.action", "Continue")`. **Expected outcome:** - All handle method calls return `None`. @@ -497,7 +502,7 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash **Preconditions:** Overlay raised with no buttons in config. **Steps:** -1. Show overlay with no `with_cancel` or `with_action` calls. +1. Show overlay with no `with_button` calls. 2. Run one frame. **Expected outcome:** @@ -508,32 +513,33 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash --- -#### TC-OVL-024 — Cancel button click enqueues the Cancel action id +#### TC-OVL-024 — Button click enqueues its action id (reframed post-outage: generic button) **Type:** kittest **Traceability:** FR-7 (AC-7.2, AC-7.3) -**Preconditions:** Overlay raised with `handle.with_cancel(OVERLAY_CANCEL_ACTION_ID)`. +**Preconditions:** Overlay raised with `with_button("overlay.cancel", "Cancel")`. There is no +built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cancel"` a caller-chosen id. **Steps:** -1. Show overlay with Cancel button. +1. Show overlay with a generic button. 2. Run one frame; assert a button with label `"Cancel"` is present. -3. Click the Cancel button (via `harness.get_by_label("Cancel").click()`). +3. Click the button (via `harness.get_by_label("Cancel").click()`). 4. Run another frame. 5. Call `ProgressOverlay::take_actions(ctx)`. **Expected outcome:** -- `take_actions` returns a list containing exactly one entry equal to `OVERLAY_CANCEL_ACTION_ID`. -- The overlay itself is NOT automatically dismissed by the click — it remains until the app loop - dispatches the cancel action and explicitly calls `handle.clear()` (the overlay is UI-only; - it never calls backend code directly). +- `take_actions` returns a list containing exactly one entry equal to the caller's id `"overlay.cancel"`. +- The overlay itself is NOT automatically dismissed by the click — it remains until the owning + screen drains the action and explicitly calls `handle.clear()` (the overlay is UI-only and knows + nothing about cancellation). --- -#### TC-OVL-025 — Generic button click enqueues its action id +#### TC-OVL-025 — Generic button click enqueues its action id (reframed post-outage: generic button) **Type:** kittest **Traceability:** FR-7 (AC-7.2) -**Preconditions:** Overlay raised with `handle.with_action("Run in background", "overlay.run_in_bg")`. +**Preconditions:** Overlay raised with `with_button("overlay.run_in_bg", "Run in background")`. **Steps:** 1. Show overlay with generic action button labelled `"Run in background"`. @@ -547,16 +553,16 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash --- -#### TC-OVL-026 — Action queue drains FIFO +#### TC-OVL-026 — Action queue drains FIFO (reframed post-outage: generic button) **Type:** kittest **Traceability:** FR-7 (AC-7.2) -**Preconditions:** Overlay raised with a Cancel button and one generic button. +**Preconditions:** Overlay raised with two generic buttons (no built-in Cancel). **Steps:** -1. Show overlay with `with_cancel("cancel")` and `with_action("Secondary", "secondary")`. -2. Click Cancel; run one frame. -3. Click Secondary; run one frame. +1. Show overlay with `with_button("cancel", "Cancel")` and `with_button("secondary", "Secondary")`. +2. Click `"Cancel"`; run one frame. +3. Click `"Secondary"`; run one frame. 4. Call `ProgressOverlay::take_actions(ctx)` once. **Expected outcome:** @@ -565,21 +571,23 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash --- -#### TC-OVL-027 — Button order: Cancel leftmost, primary action rightmost +#### TC-OVL-027 — Buttons render in insertion order (reframed post-outage: generic button) **Type:** kittest (widget-position assertion) + design-review **Traceability:** FR-7 (AC-7.4) -**Preconditions:** Overlay with `with_cancel("cancel")` and `with_action("Run in background", "bg")`. +**Preconditions:** Overlay with `with_button("first", "First action")` and +`with_button("second", "Second action")`. There is no Cancel-specific placement — buttons render +left-to-right in insertion order. **Steps:** 1. Show overlay; run one frame. -2. Query the widget rects for `"Cancel"` and `"Run in background"`. +2. Query the widget rects for `"First action"` and `"Second action"`. **Expected outcome:** -- The X coordinate of the Cancel button's rect is less than the X coordinate of the - `"Run in background"` button's rect (Cancel is to the left). -- Both buttons use `StyledButton` styling, not bare `ui.button()` (design-review: verify no - `ui.button()` call in the overlay renderer). +- The X coordinate of the first-added button's rect is less than that of the second-added button + (left-to-right insertion order). +- Both buttons use `ComponentStyles` button helpers, not bare `ui.button()` (design-review: verify + no `ui.button()` call in the overlay renderer). --- @@ -609,17 +617,17 @@ the overlay. Overlay has no buttons. **Type:** kittest **Traceability:** FR-8 (AC-8.2) -**Preconditions:** A text input widget is rendered beneath the overlay. Overlay has one Cancel +**Preconditions:** A text input widget is rendered beneath the overlay. Overlay has one generic button. **Steps:** -1. Render both the overlay (with Cancel button) and a `TextEdit` widget beneath. +1. Render both the overlay (with a button) and a `TextEdit` widget beneath. 2. Type characters `"hello"` via `harness.key_press` or equivalent. 3. Run one frame. **Expected outcome:** - The `TextEdit` content is unchanged — no characters were forwarded beneath the overlay. -- The overlay's Cancel button may have received focus (expected — the overlay captures input); +- The overlay's button may have received focus (expected — the overlay captures input); the `TextEdit` must not. **Implementation note (AC-8.2):** do not use `Ui::set_enabled(false)` (deprecated in egui @@ -815,13 +823,13 @@ the critical check is that the banner is behind the input-blocking layer). **Type:** kittest **Traceability:** FR-10 (AC-10.5) -**Preconditions:** handle_a has Cancel button with id `"cancel_a"`; handle_b (on top) has -Cancel with id `"cancel_b"`. +**Preconditions:** handle_a has a button with id `"cancel_a"`; handle_b (on top) has a button +with id `"cancel_b"` (both labelled `"Cancel"`, a caller-chosen label). **Steps:** 1. Push handle_a then handle_b. 2. Run one frame. -3. Click Cancel button. +3. Click the button. 4. Call `take_actions(ctx)`. **Expected outcome:** @@ -853,17 +861,17 @@ set on the first duplicate and checked before any `tracing::warn!` call on subse **Type:** kittest **Traceability:** NFR-3 (AC-3a) -**Preconditions:** Overlay active with one Cancel button. A text input widget exists beneath. +**Preconditions:** Overlay active with one generic button. A text input widget exists beneath. **Steps:** -1. Show overlay with Cancel. -2. Run one frame; focus starts on Cancel. +1. Show overlay with a button. +2. Run one frame; focus starts on the button. 3. Press Tab. 4. Run one frame. 5. Check focused widget. **Expected outcome:** -- Focus remains on the Cancel button (or returns to it if there is only one focusable element +- Focus remains on the button (or returns to it if there is only one focusable element in the overlay — Tab wraps within the overlay, not out of it). - The `TextEdit` beneath does not receive focus. @@ -874,26 +882,28 @@ events are not forwarded to `pass_events_to_game_while_any_popup_is_open = false --- -#### TC-OVL-042 — Esc triggers Cancel when the overlay is cancelable +#### TC-OVL-042 — Esc is swallowed even when a button is present (reframed post-outage: generic button) **Type:** kittest **Traceability:** NFR-3 (AC-3b) -**Preconditions:** Overlay raised with a Cancel button (`with_cancel(OVERLAY_CANCEL_ACTION_ID)`). +**Preconditions:** Overlay raised with a generic button (`with_button("overlay.cancel", "Cancel")`). +There is no built-in Cancel, so Esc has nothing to trigger. **Steps:** -1. Show overlay with Cancel. +1. Show overlay with a button. 2. Run one frame. 3. Press Escape (`harness.key_press(egui::Key::Escape)`). 4. Run another frame. 5. Call `take_actions(ctx)`. **Expected outcome:** -- `take_actions` returns `[OVERLAY_CANCEL_ACTION_ID]`. +- `take_actions` returns empty — Esc enqueues no action. +- `has_global(ctx)` is `true` — Esc never dismisses a hard block. - The Esc key event is consumed by the overlay (not forwarded further). --- -#### TC-OVL-043 — Esc is swallowed when the overlay has no Cancel button +#### TC-OVL-043 — Esc is swallowed when the overlay has no button (reframed post-outage: generic button) **Type:** kittest **Traceability:** NFR-3 (AC-3b) @@ -913,24 +923,24 @@ events are not forwarded to `pass_events_to_game_while_any_popup_is_open = false --- -#### TC-OVL-044 — Enter does not activate Cancel +#### TC-OVL-044 — Enter does not activate a focused button (reframed post-outage: generic button) **Type:** kittest **Traceability:** NFR-3 (AC-3c) -**Preconditions:** Overlay active with only a Cancel button; Cancel has focus. +**Preconditions:** Overlay active with a single generic button that holds focus. **Steps:** -1. Show overlay with Cancel; run one frame; ensure Cancel is focused. +1. Show overlay with a button; run one frame; ensure the button is focused. 2. Press Enter. 3. Run one frame. 4. Call `take_actions(ctx)`. **Expected outcome:** -- `take_actions` returns empty (Enter did NOT enqueue the cancel action). +- `take_actions` returns empty (Enter did NOT enqueue the focused button's action). - The overlay is still active. -**Rationale:** Enter defaults are reserved for confirm/primary actions. Cancel is a secondary -action and must never be the Enter default — see AC-3c. +**Rationale:** A hard block swallows Tab/Enter/Esc, so a focused button can never be activated by +keyboard — Enter must not trigger it. See AC-3c. --- @@ -1075,7 +1085,7 @@ loop). | NFR-6 (cheap idle) | TC-OVL-001 | | R-1 (z-order vs secret-prompt) | TC-OVL-048 | | R-2 (concurrent model) | TC-OVL-036 to TC-OVL-040 [depends on 1d] | -| R-3 (Cancel honesty) | TC-OVL-023 (no button = pure block), TC-OVL-024 (Cancel only when cancelable) | +| R-3 (button honesty; reframed post-outage) | TC-OVL-023 (no button = pure block), TC-OVL-024 (a generic button enqueues only its caller-chosen id; no implicit dismiss) | | R-4 (stuck overlay safety valve) | TC-OVL-047 [partial — BLOCKED pending 1d] | | R-7 (kittest coverage checklist) | TC-OVL-028 (input blocked), TC-OVL-042/043 (Esc), TC-OVL-015–017 (counter validation), TC-OVL-024–026 (action id FIFO), TC-OVL-045 (log-once), TC-OVL-034/035 (dismiss+banner hand-off) | @@ -1090,7 +1100,7 @@ implemented: |---|---|---| | **Concurrent overlay model** (stack vs. replace vs. reject) | TC-OVL-036 to TC-OVL-040 | Confirm stack model (AC-10.1) or choose an alternative. If not stack, TC-OVL-036–040 must be rewritten to match the chosen semantics. | | **Stuck-overlay threshold** | TC-OVL-047 | Define the threshold duration after which the elapsed readout auto-activates and the escape hatch appears. Define which operations get an escape hatch. | -| **Cancel semantics** (R-3) | TC-OVL-024, TC-OVL-042 | Confirm that the BackendTask system supports cooperative cancellation for the operations that will carry a Cancel button. Without this, TC-OVL-024 and TC-OVL-042 verify the UI-only action queue but the end-to-end cancel path is untestable. | +| **Cancellation semantics** (R-3; reframed post-outage) | TC-OVL-024, TC-OVL-042 | Cancel is no longer a built-in concept — a screen wires its own generic button to cancellation. TC-OVL-024/042 verify the UI-only action queue; the end-to-end cancel path stays untestable until the BackendTask system gains cooperative cancellation (T7). | | **Escape hatch button design** | TC-OVL-047 | Is the escape hatch a third button, a replacement for Cancel, or an entirely different mechanism? | --- diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index da827cce7..ed18a3d2e 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -8,6 +8,33 @@ --- +## Design change (post-outage) — SUPERSEDES D-5 and the Cancel-specific FR-7 + +After this plan was written, two user-mandated redesigns landed that **supersede** the +Cancel-specific decisions below. Where this document and the redesign disagree, the redesign wins: + +1. **No first-class Cancel — a generic button facility instead.** The overlay knows nothing about + cancellation. `with_cancel`, `OVERLAY_CANCEL_ACTION_ID`, and `CANCEL_LABEL` are **removed**. A + caller attaches a generic button via `OverlayConfig::with_button(id, label)` / + `OverlayHandle::with_button(id, label)`, choosing its own opaque action id and label. Clicking + enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants + — including its own cancellation. Esc/Tab/Enter are swallowed (a hard block is never keyboard- + dismissable); there is no Esc→Cancel routing. This **supersedes D-5** (the shipped-but-unwired + Cancel API) and the Cancel-specific parts of **FR-7** — "Cancel" is now merely one possible + caller-chosen label on a generic button, not a built-in concept. +2. **`Component` trait conformance (placement legitimacy).** `ProgressOverlay` now implements the + project `Component` trait: an instance holds `state: Option`, `show()` renders that + instance's card and returns a `ProgressOverlayResponse` (`DomainType = String`, the clicked + action id), and `current_value()` reports the last clicked id. The global `render_global` path is + unchanged and remains the production entry point — the `Component::show` instance path is + additive, mirroring how `MessageBanner` reconciles its global model with `Component`. This is + what makes the file legitimately placeable in `src/ui/components/`. + +T7 (backend cooperative cancellation) is unaffected, but is no longer tied to a built-in Cancel: +when real cancellation lands, a screen wires its own generic button to it. + +--- + ## 0. Reading of the Situation The requirements are sound and the test spec is thorough. My task is to remove the five diff --git a/src/app.rs b/src/app.rs index 47daacdcd..7d715371f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,7 @@ use crate::logging::initialize_logger; use crate::model::feature_gate::FeatureGate; use crate::model::settings::AppSettings; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt}; +use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ProgressOverlay}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -888,6 +888,9 @@ impl AppState { // A backend task completing after the switch could set a new banner in the new // network context — accepted risk for a local desktop app (cosmetic only). MessageBanner::clear_all_global(app_context.egui_ctx()); + // Drop any blocking overlay from the previous context so the new network + // is never left behind a stale block. + ProgressOverlay::clear_all_global(app_context.egui_ctx()); for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) @@ -1136,6 +1139,23 @@ impl AppState { } } + /// Drain pending overlay button-action clicks queued by + /// [`OverlayConfig::with_button`]/[`OverlayHandle::with_button`]. The overlay + /// is UI-only and never auto-lowers on a click — the app loop owns dispatch. + /// Buttons are a generic facility (no built-in Cancel): a screen that raised + /// the overlay via the global path registers its own action ids here. No + /// production overlay attaches a button in v1, so there is no registered + /// handler yet; unrecognised ids are logged and dropped rather than dispatched. + fn drain_overlay_actions(&mut self, ctx: &egui::Context) { + for action_id in ProgressOverlay::take_actions(ctx) { + tracing::warn!( + target = "ui::overlay", + action_id = %action_id, + "Unhandled overlay action id — dropping (no registered handler)" + ); + } + } + pub fn visible_screen_mut(&mut self) -> &mut Screen { if self.screen_stack.is_empty() { self.active_root_screen_mut() @@ -1523,6 +1543,12 @@ impl App for AppState { actions.push(self.visible_screen_mut().ui(ctx)); }; + // Blocking progress overlay: above banners, below the secret prompt. + // It consumes Esc/Tab/Enter while active, so it must render before the + // secret prompt (which is focus-raised and stays interactive above it) + // and before `handle_banner_esc` so the overlay wins Esc. + ProgressOverlay::render_global(ctx); + // Render any just-in-time passphrase prompt on top of the screen. self.render_secret_prompt(ctx); @@ -1538,6 +1564,7 @@ impl App for AppState { self.update_migration_banner(ctx, &active_context); self.handle_banner_esc(ctx); self.drain_banner_actions(ctx); + self.drain_overlay_actions(ctx); for action in actions { match action { diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 4d464e2d4..a4bfa7dbb 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -13,6 +13,7 @@ pub mod left_wallet_panel; pub mod message_banner; pub mod passphrase_modal; pub mod password_input; +pub mod progress_overlay; pub mod secret_prompt_host; pub mod selection_dialog; pub mod styled; @@ -27,4 +28,7 @@ pub use message_banner::{ BannerHandle, BannerStatus, MessageBanner, MessageBannerResponse, OptionBannerExt, OptionBannerShowExt, ResultBannerExt, }; +pub use progress_overlay::{ + OptionOverlayExt, OverlayConfig, OverlayHandle, ProgressOverlay, ProgressOverlayResponse, +}; pub use secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs new file mode 100644 index 000000000..b2e4b3d18 --- /dev/null +++ b/src/ui/components/progress_overlay.rs @@ -0,0 +1,997 @@ +//! Full-window blocking progress overlay. +//! +//! A sibling to [`MessageBanner`](super::message_banner) for operations that are +//! unsafe or meaningless to interact *around* — broadcasting a state transition, +//! signing, key import, a multi-step registration, a migration. It draws a +//! dimming plane over the whole window, blocks all interaction beneath it, and +//! shows an indeterminate [`egui::Spinner`] (no ETA), an optional discrete step +//! counter, an optional description, and optional generic action buttons. +//! +//! Like the banner, it is a **visual + input** block only — it never waits in +//! the frame loop. The owning operation runs on a tokio `BackendTask`; the +//! overlay is raised at dispatch and lowered when the `TaskResult` is polled. +//! +//! ## Buttons are a generic facility (no built-in Cancel) +//! +//! The overlay has **no** Cancel concept. A caller attaches a generic button +//! with [`OverlayConfig::with_button`] / [`OverlayHandle::with_button`], picking +//! its own opaque action id and label. Clicking the button enqueues that action +//! id; the overlay does **not** auto-lower. The owning screen drains action ids +//! via [`ProgressOverlay::take_actions`] (FIFO) and decides what to do — +//! including running its own cancellation logic if it chose to label a button +//! "Cancel". +//! +//! ## Two render paths (mirrors `MessageBanner`) +//! +//! Like `MessageBanner`, this type has both an instance [`Component`] path and a +//! global path, sharing one layout helper ([`render_card`]): +//! +//! - **Global** — state lives in egui `ctx.data`; [`ProgressOverlay::show_global`] +//! raises it, [`ProgressOverlay::render_global`] paints the full-window dim + +//! input sink + centered card once per frame from `AppState::update`. This is +//! the app-level blocking path the application depends on. +//! - **Instance** — `ProgressOverlay { state }` configured via builder methods, +//! rendered inline by [`Component::show`]. It paints only the card (no dim/sink) +//! and surfaces a clicked button's action id through [`ProgressOverlayResponse`]. +//! +//! ## Concurrency (global path) +//! +//! Global state is a **stack** of active requests. The overlay is visible while +//! the stack is non-empty; the topmost (last-pushed) entry is rendered; each +//! handle dismisses only its own entry; the overlay clears when the stack empties. +//! A human cannot launch a second blocker, so concurrency only arises from +//! programmatic tasks — the stack guarantees the UI never unblocks while an +//! operation is still running. + +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use egui::InnerResponse; +use tracing::{debug, warn}; + +use crate::ui::components::component_trait::{Component, ComponentResponse}; +use crate::ui::theme::{ComponentStyles, DashColors, Shadow, Shape, Spacing}; + +const OVERLAY_STATE_ID: &str = "__global_progress_overlay"; +const OVERLAY_ACTIONS_ID: &str = "__global_progress_overlay_actions"; +const OVERLAY_DIM_SINK_ID: &str = "__global_progress_overlay_sink"; +const OVERLAY_CARD_ID: &str = "__global_progress_overlay_card"; + +/// After this long on the topmost request the renderer auto-reveals the honest +/// elapsed readout and a reassurance line. Visual only — never auto-aborts. +const STUCK_OVERLAY_THRESHOLD: Duration = Duration::from_secs(30); + +/// Diameter of the indeterminate spinner inside the card. +const SPINNER_SIZE: f32 = 32.0; +/// Card minimum width so short content still reads as a deliberate dialog. +const CARD_MIN_WIDTH: f32 = 240.0; +/// Card maximum width so long descriptions wrap instead of stretching. +const CARD_MAX_WIDTH: f32 = 420.0; +/// Description scrolls inside the card past this height (FR-6: never off-screen). +const DESCRIPTION_MAX_HEIGHT: f32 = 160.0; + +/// Reassurance line revealed once the stuck threshold passes. +const STUCK_REASSURANCE: &str = "This is taking longer than usual."; + +/// Monotonic counter for generating unique overlay keys. +static OVERLAY_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); + +fn next_overlay_key() -> u64 { + // Relaxed is sufficient: we only need uniqueness, not ordering. The counter + // runs in a single-threaded UI context. + OVERLAY_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) +} + +/// One generic action button on the overlay card. The caller owns both the +/// `label` (an i18n unit) and the `action_id` enqueued on click. +#[derive(Clone)] +struct OverlayButton { + label: String, + action_id: String, +} + +/// Snapshot of the content fields logged for change-detection: the description +/// and the step pair. Compared to log a content update exactly once (NFR-5). +type LoggedContent = (Option, Option<(u32, u32)>); + +/// One active blocking request on the overlay stack (and the per-instance state +/// for the [`Component`] path). +#[derive(Clone)] +struct OverlayState { + key: u64, + description: Option, + /// Raw, unvalidated; the renderer gates on [`step_is_renderable`]. + step: Option<(u32, u32)>, + buttons: Vec, + /// Explicit opt-in; also forced once `created_at` passes the threshold. + show_elapsed: bool, + created_at: Instant, + /// Set once the overlay has been logged as shown (NFR-5). + logged: bool, + /// Last content logged, so a description/step update logs exactly once. + logged_content: Option, + /// Set once focus has been placed on the first button (focus trap). + focus_requested: bool, +} + +impl OverlayState { + fn new(key: u64, description: Option, config: &OverlayConfig) -> Self { + Self { + key, + description, + step: config.step, + buttons: config.buttons.clone(), + show_elapsed: config.show_elapsed, + created_at: Instant::now(), + logged: false, + logged_content: None, + focus_requested: false, + } + } +} + +/// Builder/config for [`ProgressOverlay::show_global`]. `OverlayConfig::default()` +/// is a spinner-only block: no counter, no buttons, elapsed off. +#[derive(Clone, Default)] +pub struct OverlayConfig { + description: Option, + step: Option<(u32, u32)>, + show_elapsed: bool, + buttons: Vec, +} + +impl OverlayConfig { + pub fn new() -> Self { + Self::default() + } + + /// Set the description. The `description` argument of `show_global` wins when + /// this is unset, so most callers pass the text there instead. + pub fn with_description(mut self, text: impl fmt::Display) -> Self { + let text = text.to_string(); + self.description = (!text.is_empty()).then_some(text); + self + } + + pub fn with_step(mut self, current: u32, total: u32) -> Self { + self.step = Some((current, total)); + self + } + + /// Show the honest count-up elapsed readout from the start. + pub fn with_elapsed(mut self) -> Self { + self.show_elapsed = true; + self + } + + /// Add a generic action button. The caller owns the opaque `id` enqueued on + /// click and the `label` shown on the button; clicking does not lower the + /// overlay — the owning screen drains the id and decides what to do. + pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { + self.buttons.push(OverlayButton { + label: label.to_string(), + action_id: id.to_string(), + }); + self + } +} + +/// Lifecycle handle for a raised overlay, returned by +/// [`ProgressOverlay::show_global`]. Identifies its entry by an internal key, so +/// content can be updated without losing the reference. Methods are no-ops +/// returning `None` once the entry is gone. +/// +/// INTENTIONAL(SEC-004): `OverlayHandle` is Send+Sync because `egui::Context` is +/// Send+Sync with internal locking. Acceptable for a single-threaded UI app; +/// egui's own thread-safety guarantees apply. +#[derive(Clone)] +pub struct OverlayHandle { + ctx: egui::Context, + key: u64, +} + +impl OverlayHandle { + /// Whether this handle's entry is still on the stack. + pub fn is_active(&self) -> bool { + get_overlay_state(&self.ctx) + .iter() + .any(|s| s.key == self.key) + } + + /// How long ago this entry was raised, or `None` if it is gone. + pub fn elapsed(&self) -> Option { + get_overlay_state(&self.ctx) + .iter() + .find(|s| s.key == self.key) + .map(|s| s.created_at.elapsed()) + } + + /// Update the description in place. Returns `None` if the entry is gone. + pub fn set_description(&self, text: impl fmt::Display) -> Option<&Self> { + self.mutate(|s| { + let text = text.to_string(); + s.description = (!text.is_empty()).then_some(text); + }) + } + + /// Update the step counter in place. Returns `None` if the entry is gone. + pub fn set_step(&self, current: u32, total: u32) -> Option<&Self> { + self.mutate(|s| s.step = Some((current, total))) + } + + /// Remove the step counter. Returns `None` if the entry is gone. + pub fn clear_step(&self) -> Option<&Self> { + self.mutate(|s| s.step = None) + } + + /// Attach a generic action button — opaque `id` enqueued on click + `label` + /// shown verbatim. Returns `None` if the entry is gone. + pub fn with_button(&self, id: impl fmt::Display, label: impl fmt::Display) -> Option<&Self> { + let action_id = id.to_string(); + let label = label.to_string(); + self.mutate(|s| { + s.buttons.push(OverlayButton { label, action_id }); + }) + } + + /// Dismiss only this handle's entry. The overlay lowers when the stack empties. + pub fn clear(self) { + let mut stack = get_overlay_state(&self.ctx); + let before = stack.len(); + stack.retain(|s| s.key != self.key); + if stack.len() != before { + debug!(key = self.key, "Blocking progress overlay dismissed"); + } + set_overlay_state(&self.ctx, stack); + } + + /// Find this handle's entry, apply `f`, and write the stack back. Resets the + /// content-log marker so the next render logs the change once. + fn mutate(&self, f: impl FnOnce(&mut OverlayState)) -> Option<&Self> { + let mut stack = get_overlay_state(&self.ctx); + let entry = stack.iter_mut().find(|s| s.key == self.key)?; + f(entry); + set_overlay_state(&self.ctx, stack); + Some(self) + } +} + +/// Response returned by [`ProgressOverlay::show`] (the [`Component`] path). +/// +/// The only thing a user can change about an overlay is clicking one of its +/// generic buttons, so the domain value is the clicked button's **action id**. +/// `changed_value` is `Some(action_id)` for the single frame a button is clicked +/// and `None` otherwise; the overlay is never in an "invalid" state. +#[derive(Clone)] +pub struct ProgressOverlayResponse { + action: Option, + changed: bool, +} + +impl ComponentResponse for ProgressOverlayResponse { + type DomainType = String; + + fn has_changed(&self) -> bool { + self.changed + } + + fn is_valid(&self) -> bool { + true + } + + fn changed_value(&self) -> &Option { + &self.action + } + + fn error_message(&self) -> Option<&str> { + None + } +} + +/// The blocking progress overlay. +/// +/// Two paths share the [`render_card`] layout helper (mirrors `MessageBanner`): +/// the global `ctx.data` path ([`show_global`](Self::show_global) / +/// [`render_global`](Self::render_global)), driven once per frame from +/// `AppState::update`, and the instance [`Component`] path configured by the +/// builder methods and rendered by [`Component::show`]. +pub struct ProgressOverlay { + state: Option, + /// The action id of the most recently clicked button on this instance, + /// surfaced by [`Component::current_value`]. `None` until a click occurs. + last_action: Option, +} + +impl Default for ProgressOverlay { + fn default() -> Self { + Self::new() + } +} + +impl ProgressOverlay { + // ── Instance (Component) path ─────────────────────────────────────────── + + /// Create a spinner-only instance overlay. Configure it with the builder + /// methods, then render it inline via [`Component::show`]. + pub fn new() -> Self { + let key = next_overlay_key(); + Self { + state: Some(OverlayState::new(key, None, &OverlayConfig::default())), + last_action: None, + } + } + + /// Set the description shown beneath the spinner. An empty string clears it. + pub fn with_description(mut self, text: impl fmt::Display) -> Self { + if let Some(state) = &mut self.state { + let text = text.to_string(); + state.description = (!text.is_empty()).then_some(text); + } + self + } + + /// Show a discrete step counter ("Step {current} of {total}"). + pub fn with_step(mut self, current: u32, total: u32) -> Self { + if let Some(state) = &mut self.state { + state.step = Some((current, total)); + } + self + } + + /// Show the honest count-up elapsed readout from the start. + pub fn with_elapsed(mut self) -> Self { + if let Some(state) = &mut self.state { + state.show_elapsed = true; + } + self + } + + /// Add a generic action button. The caller owns the opaque `id` surfaced + /// through [`ProgressOverlayResponse`] on click and the `label` shown on it. + pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { + if let Some(state) = &mut self.state { + state.buttons.push(OverlayButton { + label: label.to_string(), + action_id: id.to_string(), + }); + } + self + } + + // ── Global (ctx.data) path ────────────────────────────────────────────── + + /// Raise the overlay and return its handle. Non-blocking: only writes + /// `ctx.data`. The `description` argument is used unless `config` already + /// carries one. + pub fn show_global( + ctx: &egui::Context, + description: impl fmt::Display, + config: OverlayConfig, + ) -> OverlayHandle { + let description = config.description.clone().or_else(|| { + let text = description.to_string(); + (!text.is_empty()).then_some(text) + }); + let key = next_overlay_key(); + let mut stack = get_overlay_state(ctx); + if !stack.is_empty() { + // Logged here (once per show), never per frame — a blocking overlay + // cannot be stacked by a human, so this signals a programmatic smell. + warn!( + key, + depth = stack.len(), + "A blocking overlay was requested while another is active" + ); + } + stack.push(OverlayState::new(key, description, &config)); + set_overlay_state(ctx, stack); + OverlayHandle { + ctx: ctx.clone(), + key, + } + } + + /// Convenience: a spinner-only block with no text, counter, or buttons. + pub fn show_global_spinner_only(ctx: &egui::Context) -> OverlayHandle { + Self::show_global(ctx, "", OverlayConfig::default()) + } + + /// Whether any overlay is active. Cheap one-slot read (NFR-6). + pub fn has_global(ctx: &egui::Context) -> bool { + !get_overlay_state(ctx).is_empty() + } + + /// Drain the action-id queue (FIFO) for the app loop to dispatch. + pub fn take_actions(ctx: &egui::Context) -> Vec { + let actions = get_overlay_actions(ctx); + if !actions.is_empty() { + set_overlay_actions(ctx, Vec::new()); + } + actions + } + + /// Clear every entry — used on network switch alongside the banner reset. + pub fn clear_all_global(ctx: &egui::Context) { + set_overlay_state(ctx, Vec::new()); + } + + /// Render the topmost entry. Call once per frame from `AppState::update`, + /// after the panels and before the secret prompt. Early-outs to a single + /// `ctx.data` read when no overlay is active (NFR-6). + pub fn render_global(ctx: &egui::Context) { + let mut stack = get_overlay_state(ctx); + let Some(top) = stack.last_mut() else { + return; + }; + + // Full input block, scoped to the overlay-active branch so global + // shortcuts are untouched when idle. Esc/Tab/Enter are swallowed so they + // cannot dismiss the overlay, move focus to a widget beneath it, or + // activate a focused button. The overlay never implicitly dismisses — + // only a handle clear lowers it. + ctx.input_mut(|i| { + i.consume_key(egui::Modifiers::NONE, egui::Key::Escape); + i.events.retain(|e| { + !matches!( + e, + egui::Event::Key { + key: egui::Key::Tab | egui::Key::Enter, + pressed: true, + .. + } + ) + }); + }); + + let elapsed = top.created_at.elapsed(); + let stuck = stuck_reveal(elapsed); + let show_elapsed = top.show_elapsed || stuck; + log_overlay_state(top); + + let dark_mode = ctx.style().visuals.dark_mode; + let rect = ctx.content_rect(); + + // Dim + pointer sink share one Middle-order layer so the dim is always + // behind the card (a later Middle area). The sink consumes pointer + // events; its own clicks are ignored, so a backdrop click never dismisses. + let sink_layer = + egui::LayerId::new(egui::Order::Middle, egui::Id::new(OVERLAY_DIM_SINK_ID)); + ctx.layer_painter(sink_layer) + .rect_filled(rect, 0.0, DashColors::modal_overlay()); + egui::Area::new(egui::Id::new(OVERLAY_DIM_SINK_ID)) + .order(egui::Order::Middle) + .fixed_pos(rect.min) + .show(ctx, |ui| { + ui.allocate_response(rect.size(), egui::Sense::click_and_drag()); + }); + + let mut clicked = None; + egui::Area::new(egui::Id::new(OVERLAY_CARD_ID)) + .order(egui::Order::Middle) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + clicked = render_card(ui, top, dark_mode, elapsed, show_elapsed, stuck); + }); + + // The click does not lower the overlay — the app loop drains the queue + // via `take_actions` and the owning screen decides what to do. + if let Some(action_id) = clicked { + push_overlay_action(ctx, &action_id); + } + + if show_elapsed { + ctx.request_repaint_after(Duration::from_secs(1)); + } + + set_overlay_state(ctx, stack); + } +} + +impl Component for ProgressOverlay { + type DomainType = String; + type Response = ProgressOverlayResponse; + + /// Render this instance's overlay card inline (no dim/sink — the full-window + /// block is the global [`render_global`](ProgressOverlay::render_global) + /// concern). Shares the [`render_card`] layout helper with the global path. + fn show(&mut self, ui: &mut egui::Ui) -> InnerResponse { + let Some(state) = &mut self.state else { + return empty_overlay_response(ui); + }; + let dark_mode = ui.ctx().style().visuals.dark_mode; + let elapsed = state.created_at.elapsed(); + let stuck = stuck_reveal(elapsed); + let show_elapsed = state.show_elapsed || stuck; + + let clicked = render_card(ui, state, dark_mode, elapsed, show_elapsed, stuck); + if let Some(action_id) = &clicked { + self.last_action = Some(action_id.clone()); + } + let changed = clicked.is_some(); + + InnerResponse::new( + ProgressOverlayResponse { + action: clicked, + changed, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) + } + + fn current_value(&self) -> Option { + self.last_action.clone() + } +} + +/// Helper for the empty-state return in [`Component::show`]. +fn empty_overlay_response(ui: &mut egui::Ui) -> InnerResponse { + InnerResponse::new( + ProgressOverlayResponse { + action: None, + changed: false, + }, + ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()), + ) +} + +/// Whether the topmost request has been stuck long enough to reveal the honest +/// elapsed readout and reassurance line (D-4). +fn stuck_reveal(elapsed: Duration) -> bool { + elapsed >= STUCK_OVERLAY_THRESHOLD +} + +/// Whether a `(current, total)` step pair is meaningful enough to render. Hides +/// nonsense pairs (`0 of 0`, `4 of 3`, `0 of 5`) rather than painting them. +fn step_is_renderable(current: u32, total: u32) -> bool { + current >= 1 && total >= 1 && current <= total +} + +/// Log the overlay once on show and once per content change (NFR-5). +fn log_overlay_state(state: &mut OverlayState) { + let content = (state.description.clone(), state.step); + if !state.logged { + state.logged = true; + state.logged_content = Some(content); + debug!( + description = ?state.description, + step = ?state.step, + "Blocking progress overlay shown" + ); + } else if state.logged_content.as_ref() != Some(&content) { + state.logged_content = Some(content); + debug!( + description = ?state.description, + step = ?state.step, + "Blocking progress overlay updated" + ); + } +} + +/// Render the centered card contents: spinner, optional step, optional +/// description, optional elapsed/reassurance, optional button row. Returns the +/// action id of a button clicked this frame, if any. Shared by the instance +/// [`Component::show`] and the global [`ProgressOverlay::render_global`] paths. +fn render_card( + ui: &mut egui::Ui, + state: &mut OverlayState, + dark_mode: bool, + elapsed: Duration, + show_elapsed: bool, + stuck: bool, +) -> Option { + let mut clicked = None; + egui::Frame::new() + .fill(ui.ctx().style().visuals.window_fill) + .inner_margin(egui::Margin::same(Spacing::MD as i8)) + .corner_radius(Shape::RADIUS_LG as f32) + .shadow(Shadow::elevated()) + .stroke(egui::Stroke::new( + Shape::BORDER_WIDTH, + DashColors::popup_border_glow(), + )) + .show(ui, |ui| { + // Clamp the card to the window so it — and its wrapped description — + // never run off-screen in a very narrow window (FR-6 AC-6.2). + let window_width = ui.ctx().content_rect().width(); + let max_width = CARD_MAX_WIDTH.min(window_width - 2.0 * Spacing::MD); + ui.set_min_width(CARD_MIN_WIDTH.min(max_width)); + ui.set_max_width(max_width.max(0.0)); + ui.vertical_centered(|ui| { + ui.add( + egui::Spinner::new() + .size(SPINNER_SIZE) + .color(DashColors::DASH_BLUE), + ); + + if let Some((current, total)) = state.step + && step_is_renderable(current, total) + { + ui.add_space(Spacing::SM); + ui.label( + egui::RichText::new(format!("Step {current} of {total}")) + .color(DashColors::text_primary(dark_mode)) + .strong(), + ); + } + + if let Some(description) = &state.description { + ui.add_space(Spacing::SM); + egui::ScrollArea::vertical() + .id_salt(state.key) + .max_height(DESCRIPTION_MAX_HEIGHT) + .show(ui, |ui| { + ui.add( + egui::Label::new( + egui::RichText::new(description) + .color(DashColors::text_primary(dark_mode)), + ) + .wrap(), + ); + }); + } + + if show_elapsed { + ui.add_space(Spacing::XS); + ui.label( + egui::RichText::new(format!("Elapsed: {}s", elapsed.as_secs())) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + if stuck { + ui.add_space(Spacing::XS); + ui.label( + egui::RichText::new(STUCK_REASSURANCE) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + if !state.buttons.is_empty() { + ui.add_space(Spacing::MD); + clicked = render_buttons(ui, state); + } + }); + }); + clicked +} + +/// Render the generic button row left-to-right in insertion order. Returns the +/// clicked button's action id, if any. The first button is the focus stop on +/// raise, and a focus lock filter traps Tab/arrows/Esc on it so keyboard +/// navigation cannot escape to a widget beneath the block. Clicks never lower +/// the overlay. +fn render_buttons(ui: &mut egui::Ui, state: &mut OverlayState) -> Option { + let want_focus = !state.focus_requested; + let mut clicked = None; + let mut focus_stop = None; + + ui.horizontal(|ui| { + for button in &state.buttons { + let response = ComponentStyles::add_primary_button(ui, &button.label); + if focus_stop.is_none() { + focus_stop = Some(response.id); + if want_focus { + response.request_focus(); + } + } + if response.clicked() { + clicked = Some(button.action_id.clone()); + } + } + }); + + if let Some(id) = focus_stop { + // Trap keyboard focus on the block. egui resolves Tab/arrow navigation in + // `begin_pass` (before this code runs), so filtering those key events here + // is too late — only a focus lock filter on the focused widget keeps + // navigation from escaping to a widget beneath. No-op until the button has + // held focus for a frame, which the focus request above arranges. + ui.memory_mut(|m| { + m.set_focus_lock_filter( + id, + egui::EventFilter { + tab: true, + horizontal_arrows: true, + vertical_arrows: true, + escape: true, + }, + ) + }); + state.focus_requested = true; + } + clicked +} + +/// Reads the overlay stack from egui context data. +fn get_overlay_state(ctx: &egui::Context) -> Vec { + ctx.data(|d| d.get_temp::>(egui::Id::new(OVERLAY_STATE_ID))) + .unwrap_or_default() +} + +/// Writes the overlay stack to egui context data. Removes the slot when empty. +fn set_overlay_state(ctx: &egui::Context, stack: Vec) { + if stack.is_empty() { + ctx.data_mut(|d| d.remove::>(egui::Id::new(OVERLAY_STATE_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(OVERLAY_STATE_ID), stack)); + } +} + +/// Reads the pending overlay-action queue (FIFO) from egui context data. +fn get_overlay_actions(ctx: &egui::Context) -> Vec { + ctx.data(|d| d.get_temp::>(egui::Id::new(OVERLAY_ACTIONS_ID))) + .unwrap_or_default() +} + +/// Writes the pending overlay-action queue. Removes the slot when empty. +fn set_overlay_actions(ctx: &egui::Context, actions: Vec) { + if actions.is_empty() { + ctx.data_mut(|d| d.remove::>(egui::Id::new(OVERLAY_ACTIONS_ID))); + } else { + ctx.data_mut(|d| d.insert_temp(egui::Id::new(OVERLAY_ACTIONS_ID), actions)); + } +} + +/// Appends an action id to the queue. Called from the renderer on a button click. +fn push_overlay_action(ctx: &egui::Context, action_id: &str) { + let mut queue = get_overlay_actions(ctx); + queue.push(action_id.to_string()); + set_overlay_actions(ctx, queue); +} + +/// Lifecycle helpers for an `Option` screen field, mirroring +/// [`OptionBannerExt`](super::message_banner::OptionBannerExt). A dispatching +/// screen stores `op_overlay: Option`, raises it when returning +/// the `BackendTask`, and lowers it in `display_task_result` via +/// `take_and_clear()` before AppState shows the result banner. +pub trait OptionOverlayExt { + /// Take the handle (leaving `None`) and dismiss its overlay entry. + fn take_and_clear(&mut self); + + /// Clear any existing overlay, raise a new one, and store the handle. Named + /// `raise` (not `replace`) to avoid shadowing the inherent `Option::replace`. + fn raise(&mut self, ctx: &egui::Context, description: impl fmt::Display, config: OverlayConfig); +} + +impl OptionOverlayExt for Option { + fn take_and_clear(&mut self) { + if let Some(handle) = self.take() { + handle.clear(); + } + } + + fn raise( + &mut self, + ctx: &egui::Context, + description: impl fmt::Display, + config: OverlayConfig, + ) { + self.take_and_clear(); + *self = Some(ProgressOverlay::show_global(ctx, description, config)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Drives one render pass over a bare context so `ctx.data`-level effects + /// (log-once, focus) can be inspected without a kittest harness. + fn render_once(ctx: &egui::Context) { + let _ = ctx.run(egui::RawInput::default(), |ctx| { + ProgressOverlay::render_global(ctx); + }); + } + + #[test] + fn step_is_renderable_accepts_valid_and_rejects_nonsense() { + assert!(step_is_renderable(1, 1)); + assert!(step_is_renderable(3, 5)); + assert!(step_is_renderable(5, 5)); + assert!(!step_is_renderable(0, 0)); + assert!(!step_is_renderable(4, 3)); + assert!(!step_is_renderable(0, 5)); + } + + #[test] + fn stuck_reveal_triggers_only_past_threshold() { + assert!(!stuck_reveal(Duration::from_secs(0))); + assert!(!stuck_reveal( + STUCK_OVERLAY_THRESHOLD - Duration::from_millis(1) + )); + assert!(stuck_reveal(STUCK_OVERLAY_THRESHOLD)); + assert!(stuck_reveal(Duration::from_secs(60))); + } + + #[test] + fn show_pushes_entry_and_has_global_reports_it() { + let ctx = egui::Context::default(); + assert!(!ProgressOverlay::has_global(&ctx)); + let handle = ProgressOverlay::show_global(&ctx, "Loading.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + assert!(handle.is_active()); + assert!(handle.elapsed().is_some()); + } + + #[test] + fn config_with_description_wins_over_argument() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global( + &ctx, + "", + OverlayConfig::new().with_description("From config."), + ); + let stack = get_overlay_state(&ctx); + let entry = stack.iter().find(|s| s.key == handle.key).unwrap(); + assert_eq!(entry.description.as_deref(), Some("From config.")); + } + + #[test] + fn spinner_only_has_no_text() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global_spinner_only(&ctx); + let stack = get_overlay_state(&ctx); + let entry = stack.iter().find(|s| s.key == handle.key).unwrap(); + assert!(entry.description.is_none()); + assert!(entry.step.is_none()); + assert!(entry.buttons.is_empty()); + } + + #[test] + fn stale_handle_updates_are_none_and_do_not_panic() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global(&ctx, "Gone soon.", OverlayConfig::default()); + handle.clone().clear(); + assert!(handle.set_description("After clear").is_none()); + assert!(handle.set_step(1, 3).is_none()); + assert!(handle.clear_step().is_none()); + assert!( + handle + .with_button("overlay.bg", "Run in background") + .is_none() + ); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn double_clear_is_a_noop() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global(&ctx, "Once.", OverlayConfig::default()); + handle.clone().clear(); + handle.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn stack_renders_topmost_and_each_handle_clears_only_itself() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::show_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::show_global(&ctx, "Operation B.", OverlayConfig::default()); + assert!(a.is_active()); + assert!(b.is_active()); + + let stack = get_overlay_state(&ctx); + assert_eq!( + stack.last().unwrap().description.as_deref(), + Some("Operation B.") + ); + + b.clear(); + assert!(a.is_active()); + assert!(ProgressOverlay::has_global(&ctx)); + let stack = get_overlay_state(&ctx); + assert_eq!( + stack.last().unwrap().description.as_deref(), + Some("Operation A.") + ); + + a.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn take_actions_drains_fifo_then_empties() { + let ctx = egui::Context::default(); + assert!(ProgressOverlay::take_actions(&ctx).is_empty()); + push_overlay_action(&ctx, "first"); + push_overlay_action(&ctx, "second"); + assert_eq!(ProgressOverlay::take_actions(&ctx), vec!["first", "second"]); + assert!(ProgressOverlay::take_actions(&ctx).is_empty()); + } + + #[test] + fn render_logs_once_then_marks_logged() { + let ctx = egui::Context::default(); + ProgressOverlay::show_global(&ctx, "Working.", OverlayConfig::default()); + render_once(&ctx); + let stack = get_overlay_state(&ctx); + let entry = stack.last().unwrap(); + assert!(entry.logged); + assert_eq!( + entry.logged_content, + Some((Some("Working.".to_string()), None)) + ); + // A second render with no content change keeps the marker stable. + render_once(&ctx); + let stack = get_overlay_state(&ctx); + assert!(stack.last().unwrap().logged); + } + + #[test] + fn elapsed_counts_up_monotonically() { + let ctx = egui::Context::default(); + let handle = + ProgressOverlay::show_global(&ctx, "Slow.", OverlayConfig::new().with_elapsed()); + let first = handle.elapsed().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + let second = handle.elapsed().unwrap(); + assert!(second >= first, "Instant-based elapsed never counts down"); + } + + #[test] + fn option_overlay_ext_raise_swaps_entry() { + let ctx = egui::Context::default(); + let mut slot: Option = None; + slot.raise(&ctx, "First.", OverlayConfig::default()); + let first_key = slot.as_ref().unwrap().key; + slot.raise(&ctx, "Second.", OverlayConfig::default()); + let second_key = slot.as_ref().unwrap().key; + assert_ne!(first_key, second_key); + // Only the latest entry survives the swap. + let stack = get_overlay_state(&ctx); + assert_eq!(stack.len(), 1); + assert_eq!(stack.last().unwrap().key, second_key); + slot.take_and_clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + // ── Component (instance) path ─────────────────────────────────────────── + + #[test] + fn component_response_accessors_are_honest() { + // No click: not changed, valid, no error, no value. + let idle = ProgressOverlayResponse { + action: None, + changed: false, + }; + assert!(!idle.has_changed()); + assert!(idle.is_valid()); + assert!(idle.error_message().is_none()); + assert!(idle.changed_value().is_none()); + + // A click surfaces the button's action id as the changed value. + let clicked = ProgressOverlayResponse { + action: Some("overlay.bg".to_string()), + changed: true, + }; + assert!(clicked.has_changed()); + assert!(clicked.is_valid()); + assert_eq!(clicked.changed_value().as_deref(), Some("overlay.bg")); + } + + #[test] + fn component_show_renders_instance_and_reports_no_click() { + let ctx = egui::Context::default(); + let mut overlay = ProgressOverlay::new() + .with_description("Instance overlay.") + .with_step(2, 5) + .with_button("overlay.bg", "Run in background"); + // No interaction has happened yet. + assert!(overlay.current_value().is_none()); + + let _ = ctx.run(egui::RawInput::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let response = overlay.show(ui).inner; + // A frame with no click is unchanged, valid, and value-free. + assert!(!response.has_changed()); + assert!(response.is_valid()); + assert!(response.changed_value().is_none()); + }); + }); + + // current_value still None — clicks are surfaced via the response and + // recorded on the instance only when they occur. + assert!(overlay.current_value().is_none()); + } +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index f6dddefb1..690071059 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -7,6 +7,7 @@ mod info_popup; mod message_banner; mod migration_banner; mod network_chooser; +mod progress_overlay; mod restore_single_key; mod secret_prompt; mod startup; diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs new file mode 100644 index 000000000..2d6e34315 --- /dev/null +++ b/tests/kittest/progress_overlay.rs @@ -0,0 +1,910 @@ +//! Kittest coverage for the blocking progress overlay (`ProgressOverlay`). +//! +//! Mirrors the style of `tests/kittest/message_banner.rs`: a `Harness` plus +//! `query_by_label` / `query_by_role` / `ctx.data` reads. The overlay always +//! renders an animated `egui::Spinner`, which self-requests an immediate repaint +//! every frame — so these tests drive frames with `harness.step()` (one frame +//! per queued event) instead of `harness.run()` (which would spin to the step +//! cap). `show_global` is called once via `harness.ctx`, not inside the +//! per-frame closure, so the stack is not re-pushed each frame. +//! +//! Test ids map to `docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md`. +//! +//! Design-review-only invariants are asserted where possible and otherwise noted: +//! - TC-OVL-004 / TC-OVL-049 (no async/blocking in show or render): the public +//! API is entirely synchronous `ctx.data` + painting — verified by inspection +//! and by the fact these synchronous tests compile and run. +//! - TC-OVL-031 (render seam): `ProgressOverlay::render_global` is called from +//! `AppState::update` after panels, not inside `island_central_panel`. +//! - TC-OVL-032 (z-order above banners): the overlay paints on `Order::Middle`; +//! banners paint on `Order::Background` inside the central panel. +//! - TC-OVL-040 / TC-OVL-045 (log-once): covered by the inline unit tests in +//! `src/ui/components/progress_overlay.rs` (`render_logs_once_then_marks_logged`); +//! the concurrent-request warning is emitted once in `show_global`, never per frame. +//! - TC-OVL-027 (no bare `ui.button()`) / TC-OVL-029 (no `set_enabled`): the +//! renderer uses `ComponentStyles` button helpers and a top input-capturing +//! layer, never `ui.button()` or the deprecated `Ui::set_enabled`. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +use std::time::Duration; + +use dash_evo_tool::ui::MessageType; +use dash_evo_tool::ui::components::passphrase_modal::{PassphraseModalConfig, passphrase_modal}; +use dash_evo_tool::ui::components::{ + Component, ComponentResponse, MessageBanner, OptionOverlayExt, OverlayConfig, OverlayHandle, + ProgressOverlay, +}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +const SPINNER_ROLE: egui::accesskit::Role = egui::accesskit::Role::ProgressIndicator; + +/// Build a harness whose per-frame closure renders only the overlay. +fn overlay_harness() -> Harness<'static> { + Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx()); + }) +} + +// ── Group A — Idle Path ──────────────────────────────────────────────────── + +/// TC-OVL-001 — nothing renders, and `has_global` is false, when idle. +#[test] +fn tc_ovl_001_idle_renders_nothing() { + let mut harness = overlay_harness(); + harness.step(); + assert!(!ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_none()); + assert!(harness.query_by_label("Cancel").is_none()); +} + +// ── Group B — Show Lifecycle ─────────────────────────────────────────────── + +/// TC-OVL-002 — overlay appears on the next frame after show. +#[test] +fn tc_ovl_002_overlay_appears_after_show() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Registering your identity.", + OverlayConfig::default(), + ); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!( + harness + .query_by_label("Registering your identity.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-003 — show returns a usable handle (ctx.data level). +#[test] +fn tc_ovl_003_show_returns_usable_handle() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global(&ctx, "Loading.", OverlayConfig::default()); + assert!(handle.is_active()); + assert!(handle.set_description("Updated text.").is_some()); + assert!(ProgressOverlay::has_global(&ctx)); +} + +// ── Group C — Update In Place ────────────────────────────────────────────── + +/// TC-OVL-005 — description update swaps text; spinner persists. +#[test] +fn tc_ovl_005_description_update_keeps_spinner() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::show_global( + &harness.ctx, + "Preparing the funding lock.", + OverlayConfig::default(), + ); + harness.step(); + assert!( + harness + .query_by_label("Preparing the funding lock.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + + handle.set_description("Waiting for the funding proof."); + harness.step(); + assert!( + harness + .query_by_label("Waiting for the funding proof.") + .is_some() + ); + assert!( + harness + .query_by_label("Preparing the funding lock.") + .is_none() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-006 — counter update changes only the counter line. +#[test] +fn tc_ovl_006_counter_update_changes_only_counter() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::show_global( + &harness.ctx, + "Processing.", + OverlayConfig::new().with_step(2, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 2 of 5").is_some()); + + handle.set_step(3, 5); + harness.step(); + assert!(harness.query_by_label("Step 3 of 5").is_some()); + assert!(harness.query_by_label("Step 2 of 5").is_none()); + assert!(harness.query_by_label("Processing.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-007 — stale handle updates are no-ops returning None (ctx.data). +#[test] +fn tc_ovl_007_stale_handle_updates_are_none() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global(&ctx, "Soon gone.", OverlayConfig::default()); + handle.clone().clear(); + assert!(handle.set_description("After clear").is_none()); + assert!(handle.set_step(1, 3).is_none()); + assert!( + handle + .with_button("overlay.bg", "Run in background") + .is_none() + ); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +// ── Group D — Dismiss ────────────────────────────────────────────────────── + +/// TC-OVL-008 — programmatic dismiss removes the overlay. +#[test] +fn tc_ovl_008_programmatic_dismiss_removes_overlay() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + + handle.clear(); + harness.step(); + assert!(!ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_none()); + assert!(harness.query_by_label("Working.").is_none()); +} + +/// TC-OVL-009 — double dismiss is a no-op (ctx.data). +#[test] +fn tc_ovl_009_double_dismiss_is_noop() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global(&ctx, "Once.", OverlayConfig::default()); + handle.clone().clear(); + handle.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +/// TC-OVL-010 / TC-OVL-035 — failed task: overlay gone before the error banner. +/// Component-level simulation of the AppState hand-off (single-frame exclusivity). +#[test] +fn tc_ovl_010_dismiss_before_error_banner() { + let ctx = egui::Context::default(); + let overlay = ProgressOverlay::show_global(&ctx, "Registering.", OverlayConfig::default()); + assert!(overlay.is_active()); + + // Result arrives: lower the overlay, then show the banner — never both. + overlay.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + MessageBanner::set_global(&ctx, "Registration failed. Try again.", MessageType::Error); + + assert!(!ProgressOverlay::has_global(&ctx)); + assert!(MessageBanner::has_global(&ctx)); +} + +// ── Group E — Spinner ────────────────────────────────────────────────────── + +/// TC-OVL-011 — spinner is present in every configuration. +#[test] +fn tc_ovl_011_spinner_present_in_all_configs() { + let configs = [ + OverlayConfig::default(), + OverlayConfig::new().with_step(1, 3), + OverlayConfig::new() + .with_step(1, 3) + .with_button("overlay.bg", "Run in background"), + ]; + for config in configs { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global(&harness.ctx, "Busy.", config); + harness.step(); + assert!( + harness.query_by_role(SPINNER_ROLE).is_some(), + "spinner must render in every configuration" + ); + } +} + +/// TC-OVL-012 / TC-OVL-018 — no percentage / ETA element; spinner stays +/// indeterminate even with a counter. +#[test] +fn tc_ovl_012_no_eta_or_percentage() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Building the shielded transaction.", + OverlayConfig::new().with_step(2, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 2 of 5").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(harness.query_by_label_contains("%").is_none()); + assert!(harness.query_by_label_contains("remaining").is_none()); + assert!(harness.query_by_label_contains("ETA").is_none()); +} + +/// TC-OVL-013 (Part A) — elapsed readout is off by default. +#[test] +fn tc_ovl_013a_elapsed_off_by_default() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); +} + +/// TC-OVL-013 (Part B) — when enabled the elapsed readout shows and counts up. +#[test] +fn tc_ovl_013b_elapsed_on_counts_up() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Slow operation.", + OverlayConfig::new().with_elapsed(), + ); + harness.step(); + assert!(harness.query_by_label("Elapsed: 0s").is_some()); + + // Real wall-clock elapsed (Instant-based) — counts up, never down. + std::thread::sleep(Duration::from_millis(1100)); + harness.step(); + assert!( + harness.query_by_label("Elapsed: 0s").is_none(), + "the readout advanced past 0s" + ); + assert!( + harness.query_by_label_contains("Elapsed:").is_some(), + "the readout persists and never disappears or counts down" + ); +} + +// ── Group F — Step Counter ───────────────────────────────────────────────── + +/// TC-OVL-014 — a valid counter renders "Step {current} of {total}". +#[test] +fn tc_ovl_014_valid_counter_renders() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_step(3, 5), + ); + harness.step(); + assert!(harness.query_by_label("Step 3 of 5").is_some()); +} + +/// TC-OVL-015 / TC-OVL-016 / TC-OVL-017 — invalid counters hide the line. +#[test] +fn tc_ovl_015_017_invalid_counter_hidden() { + for (current, total) in [(0, 0), (4, 3), (0, 5)] { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_step(current, total), + ); + harness.step(); + assert!( + harness.query_by_label_contains("Step").is_none(), + "counter ({current},{total}) must be hidden" + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + } +} + +/// TC-OVL-019 — no counter line when none is set. +#[test] +fn tc_ovl_019_no_counter_when_unset() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Sending your transaction to the network.", + OverlayConfig::default(), + ); + harness.step(); + assert!(harness.query_by_label_contains("Step").is_none()); + assert!( + harness + .query_by_label("Sending your transaction to the network.") + .is_some() + ); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +// ── Group G — Description Text ───────────────────────────────────────────── + +/// TC-OVL-020 — description renders as a single full sentence. +#[test] +fn tc_ovl_020_description_full_sentence() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Registering your identity on the network.", + OverlayConfig::default(), + ); + harness.step(); + assert!( + harness + .query_by_label("Registering your identity on the network.") + .is_some() + ); +} + +/// TC-OVL-021 — a long description wraps and stays within the window. +#[test] +fn tc_ovl_021_long_description_within_bounds() { + let long = "Waiting for the funding proof. This operation contacts the Dash network and may take up to two minutes depending on network conditions."; + let mut harness = Harness::builder() + .with_size(egui::vec2(300.0, 400.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx()); + }); + let _handle = ProgressOverlay::show_global(&harness.ctx, long, OverlayConfig::default()); + harness.step(); + + let node = harness.query_by_label(long); + assert!( + node.is_some(), + "long description must render, not clip to empty" + ); + let rect = node.unwrap().rect(); + assert!( + rect.min.x >= -1.0 && rect.max.x <= 301.0, + "description stays within the window horizontally: {rect:?}" + ); +} + +/// TC-OVL-022 — spinner-only overlay is valid with no text, counter, or button. +#[test] +fn tc_ovl_022_spinner_only_valid() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(harness.query_by_label_contains("Step").is_none()); + assert!(harness.query_by_label("Cancel").is_none()); +} + +// ── Group H — Buttons & Actions ──────────────────────────────────────────── + +/// TC-OVL-023 — no buttons: a pure block, dismissed programmatically only. +#[test] +fn tc_ovl_023_no_buttons_pure_block() { + let mut harness = overlay_harness(); + let _handle = + ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label("Cancel").is_none()); + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-024 — clicking a generic button enqueues its caller-chosen action id; +/// the overlay persists. "Cancel" here is just a label the caller picked, not a +/// built-in concept — the facility is fully generic. +#[test] +fn tc_ovl_024_button_click_enqueues_action() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + ); + // The centered card (anchored CENTER_CENTER) needs a few frames to cache its + // size before it stops moving; settle before clicking so the click lands. + harness.step(); + harness.step(); + harness.step(); + assert!(harness.query_by_label("Cancel").is_some()); + + harness.get_by_label("Cancel").click(); + harness.step(); + assert_eq!( + ProgressOverlay::take_actions(&harness.ctx), + vec!["overlay.cancel".to_string()] + ); + // The click does not auto-dismiss — only the app loop lowers it. + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-025 — a generic button click enqueues its action id. +#[test] +fn tc_ovl_025_generic_button_click_enqueues_action() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Background-able.", + OverlayConfig::new().with_button("overlay.run_in_bg", "Run in background"), + ); + harness.step(); + harness.step(); + harness.step(); + assert!(harness.query_by_label("Run in background").is_some()); + + harness.get_by_label("Run in background").click(); + harness.step(); + assert_eq!( + ProgressOverlay::take_actions(&harness.ctx), + vec!["overlay.run_in_bg".to_string()] + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-026 — the action queue drains FIFO then empties. +#[test] +fn tc_ovl_026_action_queue_drains_fifo() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .with_button("cancel", "Cancel") + .with_button("secondary", "Secondary"), + ); + // Settle the centered card before clicking (anchored CENTER_CENTER moves for + // a couple of frames until its size is cached). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Cancel").click(); + harness.step(); + harness.get_by_label("Secondary").click(); + harness.step(); + + assert_eq!( + ProgressOverlay::take_actions(&harness.ctx), + vec!["cancel".to_string(), "secondary".to_string()] + ); + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); +} + +/// TC-OVL-027 — generic buttons render left-to-right in insertion order. The +/// renderer uses `ComponentStyles` button helpers, never a bare `ui.button()` +/// (design-review). +#[test] +fn tc_ovl_027_buttons_render_in_insertion_order() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .with_button("first", "First action") + .with_button("second", "Second action"), + ); + harness.step(); + + let first_x = harness.get_by_label("First action").rect().center().x; + let second_x = harness.get_by_label("Second action").rect().center().x; + assert!( + first_x < second_x, + "the first-added button must be left of the second" + ); +} + +// ── Group I — Input Blocking ─────────────────────────────────────────────── + +/// TC-OVL-028 — pointer clicks on the backdrop do not reach widgets beneath. +#[test] +fn tc_ovl_028_pointer_click_beneath_blocked() { + let counter = Rc::new(Cell::new(0u32)); + let counter_ui = Rc::clone(&counter); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + if ui.button("Increment").clicked() { + counter_ui.set(counter_ui.get() + 1); + } + ProgressOverlay::render_global(ui.ctx()); + }); + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + + harness.get_by_label("Increment").click(); + harness.step(); + assert_eq!( + counter.get(), + 0, + "widget beneath the overlay must not receive the click" + ); + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); +} + +/// TC-OVL-029 — keyboard input does not reach widgets beneath the overlay. +/// The renderer never uses the deprecated `Ui::set_enabled` (design-review). +#[test] +fn tc_ovl_029_keyboard_beneath_blocked() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx()); + }); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + ); + harness.step(); + + harness + .input_mut() + .events + .push(egui::Event::Text("hello".to_string())); + harness.step(); + assert!( + text.borrow().is_empty(), + "the text field beneath the overlay must not receive typed input" + ); +} + +/// TC-OVL-030 — a backdrop click does NOT dismiss the overlay. +#[test] +fn tc_ovl_030_backdrop_click_does_not_dismiss() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + + let corner = egui::pos2(10.0, 10.0); + harness.drag_at(corner); + harness.drop_at(corner); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); +} + +// ── Group J — Coexistence with MessageBanner ─────────────────────────────── + +/// TC-OVL-032 / TC-OVL-033 — banners persist in ctx.data while the overlay is +/// up and survive its dismissal; both can be active at once (overlay on top). +#[test] +fn tc_ovl_032_033_banner_persists_under_overlay() { + let ctx = egui::Context::default(); + MessageBanner::set_global(&ctx, "Banner A", MessageType::Error); + MessageBanner::set_global(&ctx, "Banner B", MessageType::Warning); + + let overlay = ProgressOverlay::show_global(&ctx, "Blocking.", OverlayConfig::default()); + assert!(MessageBanner::has_global(&ctx)); + assert!(ProgressOverlay::has_global(&ctx)); + + overlay.clear(); + assert!( + MessageBanner::has_global(&ctx), + "banner state survives the overlay lifecycle intact" + ); +} + +/// TC-OVL-034 — success task: overlay dismissed before the success banner. +#[test] +fn tc_ovl_034_dismiss_before_success_banner() { + let ctx = egui::Context::default(); + let overlay = ProgressOverlay::show_global(&ctx, "Registering.", OverlayConfig::default()); + + overlay.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); + MessageBanner::set_global( + &ctx, + "Your identity has been registered.", + MessageType::Success, + ); + + assert!(!ProgressOverlay::has_global(&ctx)); + assert!(MessageBanner::has_global(&ctx)); +} + +// ── Group K — Concurrent Operations (stack model) ────────────────────────── + +/// TC-OVL-036 — the topmost stack entry is the one rendered. +#[test] +fn tc_ovl_036_topmost_entry_rendered() { + let mut harness = overlay_harness(); + let a = ProgressOverlay::show_global(&harness.ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::show_global(&harness.ctx, "Operation B.", OverlayConfig::default()); + harness.step(); + + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_label("Operation B.").is_some()); + assert!(harness.query_by_label("Operation A.").is_none()); + assert!(a.is_active()); + assert!(b.is_active()); +} + +/// TC-OVL-037 / TC-OVL-038 — each handle dismisses only its own entry; the +/// overlay clears only when the stack empties (ctx.data). +#[test] +fn tc_ovl_037_038_handle_dismisses_only_its_own() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::show_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::show_global(&ctx, "Operation B.", OverlayConfig::default()); + + b.clear(); + assert!(a.is_active()); + assert!(ProgressOverlay::has_global(&ctx)); + + a.clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +/// TC-OVL-039 — only the topmost request's actions are reachable. +#[test] +fn tc_ovl_039_only_topmost_actions_reachable() { + let mut harness = overlay_harness(); + let _a = ProgressOverlay::show_global( + &harness.ctx, + "Operation A.", + OverlayConfig::new().with_button("cancel_a", "Cancel"), + ); + let _b = ProgressOverlay::show_global( + &harness.ctx, + "Operation B.", + OverlayConfig::new().with_button("cancel_b", "Cancel"), + ); + // Settle the centered card before clicking (anchored CENTER_CENTER moves for + // a couple of frames until its size is cached). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Cancel").click(); + harness.step(); + assert_eq!( + ProgressOverlay::take_actions(&harness.ctx), + vec!["cancel_b".to_string()], + "only the topmost request's button is reachable" + ); +} + +// ── Group L — Accessibility ──────────────────────────────────────────────── + +/// TC-OVL-041 — Tab does not cycle focus to widgets beneath the overlay. +#[test] +fn tc_ovl_041_tab_focus_trap() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx()); + }); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + ); + harness.step(); + harness.step(); + assert!( + harness.get_by_label("Cancel").is_focused(), + "the first button is the first focus stop on raise" + ); + + harness.key_press(egui::Key::Tab); + harness.step(); + assert!( + harness.get_by_label("Cancel").is_focused(), + "Tab is trapped: focus stays on the button, not a widget beneath" + ); +} + +/// TC-OVL-042 — Esc is swallowed even when a button is present; it never +/// enqueues an action (no implicit dismiss). There is no built-in Cancel, so Esc +/// has nothing to trigger — the overlay stays up. +#[test] +fn tc_ovl_042_esc_swallowed_with_button() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + ); + harness.step(); + + harness.key_press(egui::Key::Escape); + harness.step(); + assert!( + ProgressOverlay::take_actions(&harness.ctx).is_empty(), + "Esc must never trigger a button action" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// TC-OVL-043 — Esc is swallowed when the overlay has no button. +#[test] +fn tc_ovl_043_esc_swallowed_without_button() { + let mut harness = overlay_harness(); + let _handle = + ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + harness.step(); + + harness.key_press(egui::Key::Escape); + harness.step(); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "Esc must not dismiss a hard block" + ); + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); +} + +/// TC-OVL-044 — Enter does not activate a focused button. +#[test] +fn tc_ovl_044_enter_does_not_activate_button() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + ); + harness.step(); + harness.step(); + + harness.key_press(egui::Key::Enter); + harness.step(); + assert!( + ProgressOverlay::take_actions(&harness.ctx).is_empty(), + "Enter must never trigger the focused button's action" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +// ── Group M — Non-Functional ─────────────────────────────────────────────── + +/// TC-OVL-046 — switching theme mid-overlay re-renders without panic. +#[test] +fn tc_ovl_046_theme_switch_mid_overlay() { + let mut harness = overlay_harness(); + harness.ctx.set_visuals(egui::Visuals::dark()); + let _handle = ProgressOverlay::show_global( + &harness.ctx, + "Switching networks.", + OverlayConfig::default(), + ); + harness.step(); + assert!(harness.query_by_label("Switching networks.").is_some()); + + harness.ctx.set_visuals(egui::Visuals::light()); + harness.step(); + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!(harness.query_by_label("Switching networks.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); +} + +/// TC-OVL-048 — the secret-prompt modal renders above the overlay (R-1). +/// `render_global` is called before `render_secret_prompt` in `AppState::update`, +/// so the focus-raised prompt stays interactive above the overlay's dim/sink. +#[test] +fn tc_ovl_048_secret_prompt_renders_above_overlay() { + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx()); + let config = PassphraseModalConfig { + window_title: "Unlock to continue", + body: "Enter your passphrase to continue.", + hint: None, + error: None, + submit_label: "Unlock", + input_placeholder: "Enter passphrase", + }; + passphrase_modal(ui.ctx(), &config, |_| {}); + }); + let _handle = ProgressOverlay::show_global(&harness.ctx, "Signing.", OverlayConfig::default()); + harness.step(); + + assert!(ProgressOverlay::has_global(&harness.ctx)); + assert!( + harness + .query_by_label("Enter your passphrase to continue.") + .is_some(), + "the secret prompt renders above the overlay and remains visible" + ); +} + +/// TC-OVL-047 (informational portion) — the stuck-threshold reveal is exercised +/// by the inline unit test `stuck_reveal_triggers_only_past_threshold` in +/// `src/ui/components/progress_overlay.rs`; the elapsed readout and reassurance +/// line are forced on once `created_at` passes 30s. The escape-hatch button is +/// deferred with backend cancellation (T7). +#[test] +fn tc_ovl_047_stuck_threshold_is_informational_only() { + // Below the threshold a default overlay shows no elapsed readout and no + // reassurance line — the reveal is purely time-driven and benign. + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); + assert!( + harness + .query_by_label_contains("This is taking longer than usual.") + .is_none() + ); +} + +/// TC-OVL-045 (ctx.data portion) — `OptionOverlayExt::replace` swaps the entry +/// and `take_and_clear` lowers it; the log-once flag itself is asserted in the +/// inline unit tests. +#[test] +fn tc_ovl_045_option_overlay_ext_lifecycle() { + let ctx = egui::Context::default(); + let mut slot: Option = None; + slot.raise(&ctx, "First.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + slot.raise(&ctx, "Second.", OverlayConfig::default()); + assert!(ProgressOverlay::has_global(&ctx)); + slot.take_and_clear(); + assert!(!ProgressOverlay::has_global(&ctx)); +} + +// ── Group N — Component (instance) path ───────────────────────────────────── + +/// TC-OVL-050 — the `Component` instance path renders its card inline and +/// surfaces a clicked button's action id through `ProgressOverlayResponse`, +/// which `current_value` then reports. Mirrors `MessageBanner`'s instance path. +#[test] +fn tc_ovl_050_component_instance_show_reports_click() { + let action = Rc::new(RefCell::new(None::)); + let action_ui = Rc::clone(&action); + let overlay = Rc::new(RefCell::new( + ProgressOverlay::new() + .with_description("Instance overlay.") + .with_button("overlay.bg", "Run in background"), + )); + let overlay_ui = Rc::clone(&overlay); + + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let response = overlay_ui.borrow_mut().show(ui).inner; + if response.has_changed() + && let Some(id) = response.changed_value() + { + *action_ui.borrow_mut() = Some(id.clone()); + } + }); + harness.step(); + assert!(harness.query_by_label("Instance overlay.").is_some()); + assert!(harness.query_by_role(SPINNER_ROLE).is_some()); + assert!(overlay.borrow().current_value().is_none()); + + harness.get_by_label("Run in background").click(); + harness.step(); + assert_eq!(action.borrow().as_deref(), Some("overlay.bg")); + assert_eq!( + overlay.borrow().current_value().as_deref(), + Some("overlay.bg"), + "current_value reports the last clicked action id" + ); + // The instance path does not touch the global action queue — the screen + // reads the click from the response, not via take_actions. + assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); +} From a8964b26b77b46fe62de273d15f1cdf1ba00e70b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:51:08 +0200 Subject: [PATCH 05/22] docs(overlay): align D-5 and risk notes to the generic-button redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites the D-5 decision body and §8 risk #3 in place to drop the stale `with_cancel`/`OVERLAY_CANCEL_ACTION_ID` framing and describe the generic `with_button(id, label)` facility instead — consistent with the post-outage note added at the top of the plan. Documentation only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../03-dev-plan.md | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index ed18a3d2e..d96efcade 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -140,26 +140,31 @@ same honesty problem as Cancel (D-5) and waits on the same enabling work. behavior: after simulated 30 s, `Elapsed: {seconds}s` and the reassurance label appear. Mark the **escape-hatch button** portion **deferred (tracked with T7)**, not BLOCKED. -### D-5 — Cancel semantics: button-less block is the default; Cancel API ships but is unwired ⏸ +### D-5 — Button semantics: button-less block is the default; generic-button API ships, no built-in Cancel ⏸ + +> **Superseded by the "Design change (post-outage)" section at the top.** There is no +> built-in Cancel; the redesign wording below replaces the original Cancel-specific framing. **Finding (decisive):** The `BackendTask` system supports **no cooperative cancellation** (see §0.1). `handle_backend_task` discards the abort handle; `run_backend_task` takes no cancel token; the operation runs inside `block_on` on a blocking thread. **Decision:** -- The overlay **ships the full Cancel/action-id API** (`with_cancel`, `with_action`, - `take_actions`, `OVERLAY_CANCEL_ACTION_ID`) — it is UI-only, mirrors the banner, and is fully - unit/kittest-testable (TC-OVL-024/025/026/042 verify the *enqueue* path). +- The overlay **ships a generic button/action-id API** (`OverlayConfig::with_button(id, label)`, + `OverlayHandle::with_button(id, label)`, `take_actions`) — it is UI-only, mirrors the banner, + and is fully unit/kittest-testable (TC-OVL-024/025/026 verify the *enqueue* path). There is no + `with_cancel`/`OVERLAY_CANCEL_ACTION_ID`; "Cancel" is merely one possible caller-chosen label. - **The architectural default for every production caller is a button-less block.** No - production overlay may attach Cancel/actions to a `BackendTask`-backed operation until real + production overlay attaches a button to a `BackendTask`-backed operation until real cancellation lands (T7). This keeps the button honest (FR-7 AC-7.5, R-3): we never paint a - Cancel that lies. -- `AppState::drain_overlay_actions` is wired for completeness; for `OVERLAY_CANCEL_ACTION_ID` - it logs *"cancel requested but per-operation cancellation is not yet supported"* and does - **not** lower the overlay (lowering mid-op is the unsafe behavior). In v1 nothing enqueues it. + control that lies. +- `AppState::drain_overlay_actions` is wired for completeness; it drains any enqueued action ids + and (with no registered handler in v1) logs and drops them. It never lowers the overlay — a + click is surfaced to the owning screen, which decides what to do. In v1 nothing enqueues an id. -**Guidance to Marvin:** TC-OVL-024 and TC-OVL-042 remain valid as **UI-queue** tests (click/Esc -→ action id enqueued). The **end-to-end abort** half is untestable until T7; note it as such. +**Guidance to Marvin:** TC-OVL-024 and TC-OVL-042 remain valid as **UI-queue / input-block** +tests (button click → action id enqueued; Esc swallowed). Any end-to-end abort a screen builds on +top stays untestable until T7; note it as such. --- @@ -502,8 +507,9 @@ TODO so the gap is tracked, not lost. the overlay-active branch; verify global shortcuts (and the existing `handle_banner_esc`, `app.rs:1089`) still behave when no overlay is up. The banner Esc handler and overlay Esc handler must not both fire — overlay consumes Esc first (it renders earlier in the frame). -3. **No backend cancellation (D-5).** Enforce by review: no production `show_global` call may pass - `with_cancel`/`with_action` until T7. Consider a clippy-grep gate in CI. +3. **No backend cancellation (D-5).** Enforce by review: no production `show_global` call may + attach a button (`with_button`) to a `BackendTask`-backed operation until T7. Consider a + clippy-grep gate in CI. 4. **Repaint discipline.** `request_repaint_after(1s)` only when elapsed/threshold is live; the `Spinner` already self-repaints. Do not unconditionally wake an idle UI. 5. **Sink vs. secret prompt.** The Middle-order sink must not eat the secret prompt's input. The From ba9a421e9980878a467b83ebf9e82aecc9fcb8dc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 19:04:00 +0200 Subject: [PATCH 06/22] test(overlay): add ignored probe proving button-less keyboard-block gap (QA-001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TC-OVL-029 only exercises a with-button overlay, where the first button steals focus on raise, so typing is blocked incidentally rather than by the overlay's input handling. This probe raises a button-less hard block over an already-focused field (the J-2 broadcast / J-4 migration case) and asserts FR-8 AC-8.2: typed input must not reach the field beneath. The probe currently FAILS — render_global filters Tab/Enter/Esc only after the beneath widgets have consumed input that frame, and a button-less overlay has no first button to steal focus, so keystrokes leak into the focused field beneath. Marked #[ignore] so the suite stays green; un-ignore once the overlay claims keyboard focus / consumes text while active. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/kittest/progress_overlay.rs | 53 +++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 2d6e34315..1115034be 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -908,3 +908,56 @@ fn tc_ovl_050_component_instance_show_reports_click() { // reads the click from the response, not via take_actions. assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); } + +// ── QA probe (Marvin) — FR-8 AC-8.2 for the button-LESS hard block ────────── +// +// TC-OVL-029 only covers a *with-button* overlay, where the first button +// steals focus on raise — so typing is blocked incidentally, not by the +// overlay's input handling. This probe raises a *button-less* block over a +// field that already holds focus (the J-2 broadcast / J-4 migration case) and +// asserts AC-8.2: typed input must not reach the field beneath. +// +// QA-001 (HIGH): currently FAILS — `render_global` filters Tab/Enter/Esc only +// AFTER the beneath widgets have already consumed input this frame, and a +// button-less overlay has no first-button to steal focus. Typed characters +// therefore leak into a focused field beneath. Ignored so the suite stays +// green; un-ignore once the overlay claims keyboard focus / consumes text +// while active. TODO(QA-001): fix input blocking for the button-less block. +#[ignore = "QA-001 (HIGH): button-less overlay leaks typed input to a focused field beneath (FR-8 AC-8.2)"] +#[test] +fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx()); + }); + + // Focus the field beneath, before any overlay exists. + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + + // Raise a pure (button-less) block over the already-focused field. + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + + // Type. AC-8.2: keyboard input must not reach widgets beneath the overlay. + harness + .input_mut() + .events + .push(egui::Event::Text("hello".to_string())); + harness.step(); + + assert!( + text.borrow().is_empty(), + "FR-8 AC-8.2: typed input reached a focused field beneath a button-less \ + overlay: {:?}", + text.borrow() + ); +} From 6af742a4a02d290bcd9d121a6f927c82d75ad5d9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:05:04 +0200 Subject: [PATCH 07/22] fix(overlay): frame-start input claim (QA-001) + clear action queue on switch (SEC-007) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements two QA-wave findings from the design addendum (§1 A-2, §2 A-4): - QA-001 (HIGH) — button-less keyboard/text leak. `render_global`'s key filter runs at end-of-frame, one frame too late: a button-less hard block raised over an already-focused field let typed characters reach the field beneath (the J-2 broadcast / J-4 migration case). New `ProgressOverlay::claim_input(ctx)`, called near the top of `AppState::update` (before the panels) and gated on no active secret prompt, releases beneath text-edit focus and strips `Event::Text` plus the navigation/confirm keys (Tab, Enter, Escape, Space, arrows). The `#[ignore]`d probe `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath` is un-ignored and now passes. - SEC-007 — `clear_all_global` (network switch) now also drains the action queue, so a click queued just before the switch cannot survive into the new context and be mis-dispatched. Adds inline unit tests: `claim_input` strips text + nav/confirm keys while a block is up and is a no-op when idle; `clear_all_global` clears the queue. Scope note: this is a partial pass on the QA list. The end-of-frame filter in `render_global` is kept as belt-and-suspenders and is NOT yet gated on a secret prompt (marked TODO at the call site — blocker #2's full fix removes it and routes the keyboard tests through `claim_input`). Still outstanding from the addendum / task: A-1 no-progress watchdog, A-3 keyed `OverlayHandle::take_actions` + `sweep_orphan_actions`, instance `Component::show` focus-trap separation, secondary-button styling, 30s clock seam, Foreground layering, and doc sync. Also adds Nagatha's `04-design-addendum.md` (the authoritative spec). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../04-design-addendum.md | 330 ++++++++++++++++++ src/app.rs | 9 + src/ui/components/progress_overlay.rs | 164 ++++++++- tests/kittest/progress_overlay.rs | 14 +- 4 files changed, 505 insertions(+), 12 deletions(-) create mode 100644 docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md new file mode 100644 index 000000000..a99b03807 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -0,0 +1,330 @@ +# Blocking Progress Overlay — Design Addendum (QA wave resolutions) + +**Phase:** 1d (Architecture) — addendum to `03-dev-plan.md` +**Author:** Nagatha (Software Architect) +**Date:** 2026-06-17 +**Resolves:** SEC-003, Diziet F-5 (Decision 1 · Safety-Valve); Diziet F-2, SEC-007 +(Decision 2 · Action-Dispatch). Touches QA-001 (button-less input leak) as a dependency of +Decision 1. +**Status:** Decided. Bilby builds directly from §1 and §2. One sub-question flagged for the user +(§1, "For the user to weigh in"). +**Supersedes:** the open portions of D-4 (escape-hatch) and D-5 (`drain_overlay_actions` policy) +in `03-dev-plan.md`. Where this addendum and the plan disagree, this addendum wins. + +--- + +## 0. Reading of the two problems + +Both findings share one root cause: **the overlay's lifecycle is owned entirely by callers, but +the caller wiring was never finished.** A button-less block trusts the owning operation to clear +its handle; a button trusts the owning screen to receive its click. QA proved both trusts are +currently un-backed — a hang traps the UI forever (SEC-003/F-5), and a click is drained globally +and dropped before any screen sees it (F-2). I am not adding a new owner; I am making the existing +ownership contract real, and adding the minimum machinery so a *violation* is loud rather than +silent. + +A second truth shapes Decision 1. The operations that use a button-less block — broadcast, +signing, key import, migration — are exactly the ones it is **unsafe to background**. So any +renderer-level "dismiss / continue in background" valve would reintroduce the precise hazard the +overlay exists to prevent, for exactly the population of overlays it would apply to. The escape +from a trap therefore cannot be "let the user act mid-op"; it must be "guarantee the block always +lowers through the normal path, and make a stuck block impossible by construction." + +--- + +## 1. Decision 1 — Stuck/hang safety-valve (SEC-003, Diziet F-5) + +### Decision + +**Keep the block total. Ship NO renderer-level dismiss/background button in v1.** The safety valve +is a *layered guarantee that the block always lowers through the normal path*, not an escape that +unblocks the UI while an unsafe operation is still running. Three layers: + +1. **Caller contract (the real fix), two clauses — both enforced by review:** + - **C1 — Clear on every terminal path.** A screen that raises a global overlay stores + `op_overlay: Option` and MUST call `take_and_clear()` on **both** the success + and the error branch of `display_task_result`. (The dev-plan's FR-9 hand-off already says + this; C1 makes it a hard contract, not a convention.) + - **C2 — Bounded operation.** A button-less block may only cover an operation that is + **guaranteed to terminate** — every network/IO wait inside its `BackendTask` path has a + timeout that surfaces as a `TaskError`. This is the clause that makes "trap forever" + impossible *without* fake cancellation: a bounded op always produces a `TaskResult`, which + always triggers C1, which always lowers the block. + +2. **Informational escalation (renderer-level, honest, no escape) — two thresholds:** + - **Soft, 30 s total elapsed** (`STUCK_OVERLAY_THRESHOLD`, unchanged): force-reveal the honest + `Elapsed: {seconds}s` readout and the reassurance line *"This is taking longer than usual."* + Visual only. (Existing behaviour; retained verbatim.) + - **Watchdog, 120 s with no progress** (`STUCK_OVERLAY_WATCHDOG_THRESHOLD`, new): the + reassurance line escalates to *"This is taking much longer than expected. The operation is + still running — please keep the app open."* "No progress" is measured from a new + `last_progress_at: Instant`, bumped whenever the description or step actually changes — so a + legitimately-advancing multi-step flow (J-1, four ~minute steps) **never** trips it, while a + genuinely wedged single step does. + +3. **Developer watchdog (the leak detector for C1/C2 violations):** when the watchdog threshold is + crossed, fire a **one-shot** `tracing::error!` (guarded by a `watchdog_logged` flag, logged + once, never per frame) naming the over-long overlay: an overlay alive this long without progress + is almost always a leaked handle (C1) or an un-bounded op (C2) — i.e. a bug. **No `debug_assert` + / panic** — a time-based assert is flaky (a slow test or a legitimately slow op would panic the + process). The log is the signal; CI and review are the gate. + +**Safety side — the block must be *genuinely* total (resolves QA-001).** Because nothing lowers the +block mid-op, the block must actually capture all input even when it has no buttons. Today +`render_global` filters Tab/Enter/Esc *after* the panels beneath already ran this frame, and never +filters `Event::Text` at all — so a button-less block leaks typed characters into a focused field +beneath (the J-2/J-4 case; `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath`, +currently `#[ignore]`). Fix: add `ProgressOverlay::claim_input(ctx)`, called **near the top of +`AppState::update`** (after the shutdown guard at `app.rs:1264`, **before** the visible screen's +`ui()` at `app.rs:1543`), gated on `has_global(ctx)`: + - **Release beneath focus on raise** so a focused text field stops drawing a caret and stops + consuming text (move focus off any beneath widget; the existing focus-lock pattern in + `render_buttons` at `progress_overlay.rs:690` is the buttoned-case analogue). + - **Strip `Event::Text` and the navigation/confirm keys (Tab/Enter/Esc, arrows) from + `i.events` at frame start**, so widgets beneath never observe them. Doing it *before* the + screen runs is the whole point — the current end-of-frame filter in `render_global` is one + frame too late. Keep the in-`render_global` filter as a belt-and-suspenders second pass, or + remove it once `claim_input` is verified; do not rely on it alone. + - **Button-less (all v1 ops): total keyboard + text claim.** When a caller later attaches + buttons (post-T7), `claim_input` still strips text and beneath-navigation, and the overlay's + own button area re-grants only its buttons' navigation via the existing + `set_focus_lock_filter`. + +### Rationale + +- **Why no background/dismiss valve.** It is unsafe for exactly the overlays it would cover, and + it cannot be made safe without either (a) real cooperative cancellation (does not exist — T7, + confirmed in dev-plan §0.1) or (b) per-operation in-flight guards (do not exist). The directive + forbids fake cancellation; a valve that lowers the block while a broadcast/migration runs is + fake safety. The honest move is to remove the *possibility* of a hang, not to paper a dismiss + button over it. +- **Why the trap, weighed against the alternative, is the lesser harm — and why C2 dissolves it.** + Trapping until force-quit is *recoverable* (restart; a bounded op will have completed or failed + cleanly). Letting the user fire a conflicting second op (double broadcast, interrupted + migration) is potentially *unrecoverable* — fund loss or corrupted state. Given that asymmetry, + the block must stay total. C2 then removes the only path to a real trap: if every blocked op + terminates, the block always lowers on its own. The 30 s/120 s reveals keep the *waiting* user + informed without ever offering an unsafe exit. +- **Why measure the watchdog on no-progress, not total elapsed.** A correct multi-step flow can + legitimately run several minutes; keying the watchdog (and its escalated copy) to time-since- + last-progress makes it fire only on a true stall, eliminating false dev-error logs and false + "much longer than expected" copy during healthy long flows. +- **Why QA-001 belongs here.** "Keep the block total" is only sound if the block is *actually* + total. The button-less keyboard/text leak is the one place it currently is not; fixing it is a + precondition of this decision, not a separate nicety. + +### Implementation spec + +**File: `src/ui/components/progress_overlay.rs`** + +- Add constant: + ```rust + /// After this long *without progress* on the topmost request, escalate the + /// reassurance copy and fire the one-shot developer watchdog. A leaked handle + /// (C1) or an un-bounded op (C2) is the usual cause — both are bugs. + const STUCK_OVERLAY_WATCHDOG_THRESHOLD: Duration = Duration::from_secs(120); + ``` +- `OverlayState`: add `last_progress_at: Instant` (init `Instant::now()` in `OverlayState::new`) + and `watchdog_logged: bool` (init `false`). In `OverlayHandle::mutate` (and the instance + builders that change content), set `last_progress_at = Instant::now()` **only when content + actually changes** — reuse the content-change detection already in `log_overlay_state` + (compare `(description, step)`); bump `last_progress_at` on the same edge that logs an update. +- New helpers, mirroring `stuck_reveal`: + ```rust + fn watchdog_tripped(last_progress: Instant) -> bool { + last_progress.elapsed() >= STUCK_OVERLAY_WATCHDOG_THRESHOLD + } + ``` +- `render_card`: when `watchdog_tripped`, render the escalated line + (`STUCK_WATCHDOG_REASSURANCE`) **instead of** the soft `STUCK_REASSURANCE` (do not stack both). + The soft 30 s elapsed-reveal logic is unchanged. +- `render_global`: after computing `stuck`, compute `watchdog` from `top.last_progress_at`; if + `watchdog && !top.watchdog_logged`, `tracing::error!(key = top.key, "Blocking overlay has shown + no progress for over 2 minutes — likely a leaked handle or an un-bounded operation")` and set + `top.watchdog_logged = true`. Keep the existing `request_repaint_after(1s)` when + `show_elapsed || watchdog` so the escalation actually appears. +- New `pub fn claim_input(ctx: &egui::Context)`: early-out when `!has_global(ctx)`; otherwise + release beneath focus and strip `Event::Text` + Tab/Enter/Esc/arrow key events from + `i.events`. Document that it must run before the panels each frame. + +**File: `src/app.rs`** + +- In `update()`, immediately after the shutdown guard (`:1264`) and before the screen `ui()` + (`:1543`): + ```rust + ProgressOverlay::claim_input(ctx); // total input block at frame start (button-less safe) + ``` +- Document the C1/C2 caller contract at the `op_overlay` convention site (T4 docs) and add a + one-line review rule to §8 risks: *"A button-less global block may only cover a bounded + operation (every wait times out to a TaskError)."* + +**i18n strings (complete sentences, named placeholders — NFR-2):** + +| Const | Text | +|---|---| +| `STUCK_REASSURANCE` (existing) | `This is taking longer than usual.` | +| `STUCK_WATCHDOG_REASSURANCE` (new) | `This is taking much longer than expected. The operation is still running — please keep the app open.` | +| `Elapsed: {seconds}s` (existing) | unchanged | + +### For the user to weigh in + +The only genuinely contested point: **do we ever want a manual backgrounding escape as +belt-and-suspenders, in case C2 is violated somewhere we missed?** I have decided **no** for v1, +because the safe version of it requires per-operation in-flight guards (so a backgrounded op +cannot be duplicated), which is net-new work properly scoped alongside T7 (cooperative +cancellation). My recommendation is to invest in C2 + the watchdog now and revisit a *safe* +backgrounding valve only if the watchdog log ever fires in practice. If the user values guaranteed +availability over the conflicting-op risk more highly than I have weighted it, that is their call +to make — flagging it rather than burying it. + +### Test obligations + +- **Un-ignore** `qa_buttonless_overlay_blocks_typing_into_focused_field_beneath` (QA-001); it must + pass once `claim_input` lands. This is the acceptance test for "the block is genuinely total." +- TC-OVL-047 (informational portion) stays green; **add** an inline unit test + `watchdog_tripped_only_past_threshold` mirroring `stuck_reveal_triggers_only_past_threshold`. +- New inline test: a content update via `set_step`/`set_description` resets `last_progress_at` + (the watchdog does not fire on a progressing flow). +- New inline test: `watchdog_logged` flips once and stays set (no per-frame log spam — NFR-5). +- kittest: button-less overlay with an injected long elapse renders `STUCK_WATCHDOG_REASSURANCE`, + not the soft line, and still exposes no dismiss control. +- Escape-hatch button portion of TC-OVL-047 is **closed as "won't build" for v1** (was "deferred + to T7"): there is no renderer escape by design. Note it as a deliberate non-feature. + +--- + +## 2. Decision 2 — Action-dispatch contract (Diziet F-2, SEC-007) + +### Decision + +**The caller receives its own clicks through its own handle. Actions are scoped by the owning +overlay entry's key; the global drain becomes a true orphan-sweeper that can never pre-empt a +live owner.** Concretely: + +1. **Actions are keyed.** The action queue stores `(key, action_id)`, not bare `action_id`. A + click in `render_global` enqueues the **topmost entry's key** alongside the id. +2. **The owning caller drains via its handle.** New + `OverlayHandle::take_actions(&self) -> Vec` returns (FIFO) and removes only the action + ids whose key matches this handle, **leaving other entries' actions untouched**. The owning + screen calls it at the **top of its own `ui()`** each frame and matches its own ids: + ```rust + if let Some(h) = &self.op_overlay { + for action_id in h.take_actions() { + // caller-owned logic, e.g. the screen's own cancellation + } + } + ``` + This is the literal implementation of the directive "the caller RECEIVES click events" — no + central registry, caller owns semantics. The instance `Component` path already does the + equivalent by surfacing the click through `ProgressOverlayResponse` (unchanged). +3. **`OverlayHandle::clear(self)` also purges its key's pending actions**, so a normal dismiss + leaves no stray id behind to be swept and logged. +4. **The global drain is demoted to an orphan-sweeper.** Rename the static + `ProgressOverlay::take_actions(ctx)` → **`sweep_orphan_actions(ctx) -> Vec`**: it + drains and returns only actions whose key is **no longer on the stack** (owner already cleared + or dropped its handle without draining). `AppState::drain_overlay_actions` calls it and logs + each truly-orphaned id (`warn!`, "overlay action received for an overlay that is no longer + active — dropping"). Because it only ever takes dead-owner ids, it **cannot** race or pre-empt + the screen that owns a live overlay, regardless of call order — so it may stay at its current + position (`app.rs:1567`). +5. **App-level owners use the same mechanism.** When `AppState` itself raises an overlay (e.g. a + future network-switch block with a button), it holds the `OverlayHandle` and drains it with + `take_actions()` exactly like a screen — `AppState` is just another caller. There is one + dispatch mechanism, not a screen-path and an app-path. (This is *more* consistent than copying + the banner's central-registry `drain_banner_actions`; it honours "caller owns logic" uniformly.) +6. **Network-switch hygiene (SEC-007).** `clear_all_global(ctx)` MUST clear the action queue too — + not just the state stack. Today it clears only `OVERLAY_STATE_ID`, so a click queued just + before a network switch survives into the new context and could be mis-dispatched. Add a clear + of `OVERLAY_ACTIONS_ID` (remove the slot) inside `clear_all_global`. + +### Rationale + +- **Keying, not a registry.** Scoping actions by the owning entry's key is the minimum needed to + let two parties drain one queue without stealing each other's ids — it is data scoping, not a + handler registry, and it is what makes "caller owns logic" *safe* under the single-global-queue + model the banner established. With a bare-string drain-all queue and two drainers, whoever runs + first swallows everything; ordering tricks cannot fix that without re-enqueue hacks. Keying + dissolves the race by construction. +- **Why the handle is the delivery channel.** The handle is already the caller's one reference to + its overlay (it updates content and clears through it). Delivering clicks through the same + handle is the most cohesive surface and needs no new plumbing in screens beyond the `op_overlay` + field they already hold for the FR-9 hand-off. +- **Why the global drain survives at all.** A screen can be popped between a click (frame N) and + its next `ui()` (frame N+1); its handle is dropped without draining. Those ids would otherwise + accumulate in `ctx.data` forever. The orphan-sweeper reclaims exactly those and logs them as the + anomaly they are — observability without interference. +- **Action-id convention.** Follow the live banner convention `MIGRATION_RETRY_ACTION_ID = + "migration:retry:finish_unwire"` — colon-namespaced `domain:object:action`, declared as a + `pub const &str` near the owning screen (e.g. `shielded:build:cancel`). The dev-plan's earlier + `overlay.cancel`/dot form is superseded; align to colons. (`OVERLAY_CANCEL_ACTION_ID` was already + removed in the post-outage redesign, so there is no built-in id to reconcile.) + +### Implementation spec + +**File: `src/ui/components/progress_overlay.rs`** + +- New private type: + ```rust + #[derive(Clone)] + struct OverlayAction { key: u64, action_id: String } + ``` + Queue type changes `Vec` → `Vec` in `get/set_overlay_actions`. +- `push_overlay_action(ctx, key: u64, action_id: &str)` — `render_global` passes `top.key`; the + instance `Component` path does not enqueue (it returns via the response, unchanged). +- `OverlayHandle::take_actions(&self) -> Vec`: read queue, partition by `key == self.key`, + write back the non-matching remainder, return matching ids in order. +- `OverlayHandle::clear(self)`: after `retain`-ing the state stack, also `retain` the action queue + to drop `key == self.key` entries. +- Rename static `take_actions(ctx)` → `sweep_orphan_actions(ctx) -> Vec`: returns ids whose + `key` is absent from the current state stack; writes back the rest. +- `clear_all_global(ctx)`: clear `OVERLAY_STATE_ID` (existing) **and** `OVERLAY_ACTIONS_ID` (new). + +**File: `src/app.rs`** + +- `drain_overlay_actions`: replace the blanket `take_actions` loop with + `for id in ProgressOverlay::sweep_orphan_actions(ctx) { warn!(... "no longer active — dropping") }`. + Keep it at `:1567`. +- Document at the `op_overlay` convention site (T4 docs): *screens drain their own clicks at the + top of `ui()` via `OverlayHandle::take_actions()` and match their own colon-namespaced ids; the + app loop only sweeps orphans.* + +### Test obligations + +- **Reframe** TC-OVL-024/025/026 to the handle-scoped API: click enqueues this handle's id; + `handle.take_actions()` returns it FIFO then empties; an unrelated handle's `take_actions()` + returns empty (no cross-owner theft). +- **Update** inline `take_actions_drains_fifo_then_empties` → exercise `OverlayHandle::take_actions` + (per-key FIFO) plus `sweep_orphan_actions` (dead-owner ids only). +- New inline test: two stacked overlays A (bottom) and B (top); a click enqueues against B's key; + `A.take_actions()` is empty, `B.take_actions()` returns the id. +- New inline test (SEC-007): enqueue an action, then `clear_all_global`; the action queue is empty. +- New inline test: a handle dropped without draining leaves its id only reachable via + `sweep_orphan_actions`; `clear()` instead leaves nothing for the sweeper. +- kittest: a screen-style harness drains its handle and observes its own id; `drain_overlay_actions` + logs nothing for a live owner. + +--- + +## 3. Decision summary + +| # | Decision | Status | +|---|---|---| +| A-1 | No renderer dismiss/background valve; safety = caller contract (C1 clear-on-terminal, C2 bounded-op) + 30 s soft / 120 s-no-progress escalation + one-shot dev watchdog | Decided (one sub-point flagged for user) | +| A-2 | Button-less block must be genuinely total: `claim_input` at frame start releases beneath focus + strips text/nav keys (resolves QA-001) | Decided | +| A-3 | Clicks delivered to the caller via keyed `OverlayHandle::take_actions`; global drain demoted to `sweep_orphan_actions` | Decided | +| A-4 | `clear_all_global` clears the action queue too (SEC-007) | Decided | + +--- + +## 4. Candy tally + +| Severity | Count | Items | +|---|---|---| +| **High** | 3 | A-1 hang/trap resolution without fake cancellation (SEC-003/F-5); A-2 button-less total-input block (QA-001); A-3 finished receive-side dispatch (F-2) | +| **Medium** | 2 | C2 bounded-operation contract (makes "trap forever" impossible by construction); A-4 action-queue network-switch hygiene (SEC-007) | +| **Low** | 1 | No-progress watchdog metric + one-shot dev-error (leak detector for C1/C2; no flaky time-based assert) | + +**Total: 6 findings.** Six candies — and not one of them required teaching the spinner to lie about +how long it will take. The overlay keeps its dignity: it blocks honestly, it reports honestly, and +when something is truly wedged it says so plainly rather than offering a door that opens onto a +cliff. diff --git a/src/app.rs b/src/app.rs index 7d715371f..129b02e64 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1533,6 +1533,15 @@ impl App for AppState { } } + // Total input block at frame start: while a blocking overlay is up, claim + // all keyboard + text input BEFORE the panels run, so a button-less block + // cannot leak typed characters into a focused field beneath (QA-001). + // Gated on no active secret prompt — that modal renders above the overlay + // and must keep the keyboard (e.g. Esc to cancel a passphrase entry). + if self.active_secret_prompt.is_none() { + ProgressOverlay::claim_input(ctx); + } + // Show welcome screen if onboarding not completed let mut actions = Vec::new(); if self.show_welcome_screen diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index b2e4b3d18..5e6a8bd2a 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -412,8 +412,61 @@ impl ProgressOverlay { } /// Clear every entry — used on network switch alongside the banner reset. + /// + /// SEC-007: also clears the pending action queue, so a click queued just + /// before a network switch cannot survive into the new context and be + /// mis-dispatched there. pub fn clear_all_global(ctx: &egui::Context) { set_overlay_state(ctx, Vec::new()); + set_overlay_actions(ctx, Vec::new()); + } + + /// Claim all keyboard and text input for the active block, at frame start. + /// + /// Must be called near the top of `AppState::update` — **before** the panels + /// and the visible screen run — and the caller MUST skip it while a secret + /// prompt is active above the overlay (that modal needs the keyboard). + /// Early-outs when no overlay is active. + /// + /// Why a separate frame-start pass: `render_global`'s own key filter runs at + /// the *end* of the frame, one frame too late for a button-less block raised + /// over an already-focused field — the field beneath has already consumed the + /// keystroke. `claim_input` closes that leak (QA-001) by, while a block is up: + /// - releasing text-edit focus from any field beneath (so it stops drawing a + /// caret and consuming text — affects only text widgets, never an overlay + /// button), and + /// - stripping `Event::Text` and the navigation/confirm keys (Tab, Enter, + /// Escape, Space, arrows) from `i.events` so nothing beneath observes them. + /// + /// A hard block is never keyboard-dismissable or keyboard-activatable. The + /// buttoned case (post-T7) re-grants its own buttons' navigation via the + /// focus-lock filter in `render_buttons`. + pub fn claim_input(ctx: &egui::Context) { + if !Self::has_global(ctx) { + return; + } + // Only releases text-edit focus, so an overlay button keeps its focus. + ctx.memory_mut(|m| m.stop_text_input()); + ctx.input_mut(|i| { + i.events.retain(|e| { + !matches!( + e, + egui::Event::Text(_) + | egui::Event::Key { + key: egui::Key::Tab + | egui::Key::Enter + | egui::Key::Escape + | egui::Key::Space + | egui::Key::ArrowUp + | egui::Key::ArrowDown + | egui::Key::ArrowLeft + | egui::Key::ArrowRight, + pressed: true, + .. + } + ) + }); + }); } /// Render the topmost entry. Call once per frame from `AppState::update`, @@ -425,11 +478,15 @@ impl ProgressOverlay { return; }; - // Full input block, scoped to the overlay-active branch so global - // shortcuts are untouched when idle. Esc/Tab/Enter are swallowed so they - // cannot dismiss the overlay, move focus to a widget beneath it, or - // activate a focused button. The overlay never implicitly dismisses — - // only a handle clear lowers it. + // Belt-and-suspenders end-of-frame filter. The primary, gated block is + // `claim_input` at frame start (which also yields to an active secret + // prompt); this second pass swallows Esc/Tab/Enter so they cannot dismiss + // the overlay, move focus beneath it, or activate a focused button. + // TODO(QA blocker #2): this pass is NOT gated on an active secret prompt, + // so it still swallows Esc while a passphrase modal is up above the + // overlay. Once `claim_input` is proven sufficient, remove this filter and + // route the keyboard tests through `claim_input` so the gated path is the + // only swallow — then a secret prompt's Esc-to-cancel survives. ctx.input_mut(|i| { i.consume_key(egui::Modifiers::NONE, egui::Key::Escape); i.events.retain(|e| { @@ -900,6 +957,103 @@ mod tests { assert!(ProgressOverlay::take_actions(&ctx).is_empty()); } + /// SEC-007 — `clear_all_global` (network switch) drains the action queue too, + /// so a click queued just before the switch cannot survive into the new + /// context and be mis-dispatched. + #[test] + fn clear_all_global_clears_action_queue() { + let ctx = egui::Context::default(); + ProgressOverlay::show_global_spinner_only(&ctx); + push_overlay_action(&ctx, "shielded:build:cancel"); + + ProgressOverlay::clear_all_global(&ctx); + + assert!(!ProgressOverlay::has_global(&ctx), "state stack is cleared"); + assert!( + ProgressOverlay::take_actions(&ctx).is_empty(), + "SEC-007: the action queue must be cleared on a network switch" + ); + } + + /// A pressed key-down `Event::Key` with no modifiers, for input tests. + fn key_down(key: egui::Key) -> egui::Event { + egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + } + } + + /// QA-001 — while a block is up, `claim_input` strips typed text and the + /// navigation/confirm keys (Tab/Enter/Escape/Space/arrows) so nothing beneath + /// the block observes them. + #[test] + fn claim_input_strips_text_and_nav_keys_when_block_active() { + let ctx = egui::Context::default(); + ProgressOverlay::show_global_spinner_only(&ctx); + + let leaked = std::cell::Cell::new(true); + let raw = egui::RawInput { + events: vec![ + egui::Event::Text("hello".to_string()), + key_down(egui::Key::Tab), + key_down(egui::Key::Enter), + key_down(egui::Key::Escape), + key_down(egui::Key::Space), + key_down(egui::Key::ArrowDown), + ], + ..Default::default() + }; + let _ = ctx.run(raw, |ctx| { + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + leaked.set(i.events.iter().any(|e| { + matches!( + e, + egui::Event::Text(_) + | egui::Event::Key { + key: egui::Key::Tab + | egui::Key::Enter + | egui::Key::Escape + | egui::Key::Space + | egui::Key::ArrowDown, + pressed: true, + .. + } + ) + })); + }); + }); + assert!( + !leaked.get(), + "claim_input must strip all text + nav/confirm key-down events while a block is up" + ); + } + + /// `claim_input` is a no-op when no overlay is active — it must not eat input + /// from the rest of the app. + #[test] + fn claim_input_is_noop_when_idle() { + let ctx = egui::Context::default(); + let kept = std::cell::Cell::new(false); + let raw = egui::RawInput { + events: vec![egui::Event::Text("hi".to_string())], + ..Default::default() + }; + let _ = ctx.run(raw, |ctx| { + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + kept.set(i.events.iter().any(|e| matches!(e, egui::Event::Text(_)))); + }); + }); + assert!( + kept.get(), + "claim_input must not strip input when no block is active" + ); + } + #[test] fn render_logs_once_then_marks_logged() { let ctx = egui::Context::default(); diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 1115034be..f37b80d66 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -917,13 +917,11 @@ fn tc_ovl_050_component_instance_show_reports_click() { // field that already holds focus (the J-2 broadcast / J-4 migration case) and // asserts AC-8.2: typed input must not reach the field beneath. // -// QA-001 (HIGH): currently FAILS — `render_global` filters Tab/Enter/Esc only -// AFTER the beneath widgets have already consumed input this frame, and a -// button-less overlay has no first-button to steal focus. Typed characters -// therefore leak into a focused field beneath. Ignored so the suite stays -// green; un-ignore once the overlay claims keyboard focus / consumes text -// while active. TODO(QA-001): fix input blocking for the button-less block. -#[ignore = "QA-001 (HIGH): button-less overlay leaks typed input to a focused field beneath (FR-8 AC-8.2)"] +// QA-001 (HIGH), RESOLVED: `ProgressOverlay::claim_input`, called at frame start +// (before the panels) while a block is up, releases beneath text focus and +// strips `Event::Text` + nav/confirm keys — so a button-less block no longer +// leaks typed input into a focused field beneath. This harness mirrors the app +// loop: `claim_input` runs before the field, `render_global` paints after it. #[test] fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { let text = Rc::new(RefCell::new(String::new())); @@ -931,6 +929,8 @@ fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { let mut harness = Harness::builder() .with_size(egui::vec2(420.0, 360.0)) .build_ui(move |ui| { + // Mirrors AppState::update: claim input at frame start, before panels. + ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); ProgressOverlay::render_global(ui.ctx()); From ddc40756b41ecc37552f5d43d65ca8d66cb3741c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:37:07 +0200 Subject: [PATCH 08/22] =?UTF-8?q?fix(overlay):=20QA-wave=20hardening=20?= =?UTF-8?q?=E2=80=94=20watchdog,=20keyed=20dispatch,=20secondary=20buttons?= =?UTF-8?q?,=20Foreground,=20focus=20separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the design addendum (§1/§2) plus the rest of the QA fix list and the three cross-finding reconciliations. All on top of the earlier claim_input/SEC-007 pass. Addendum §1 (safety-valve / A-1): - 120 s no-progress watchdog: STUCK_OVERLAY_WATCHDOG_THRESHOLD, OverlayState { last_progress_at, watchdog_logged }, watchdog_tripped() clock seam, escalated STUCK_WATCHDOG_REASSURANCE (replaces the soft line, never stacks), one-shot tracing::error! (no flaky time-based panic). last_progress_at is bumped on a real content change, reusing log_overlay_state's change detection, so a progressing multi-step flow never trips it. Addendum §2 (action-dispatch / A-3, SEC-007/A-4): - Actions are keyed: OverlayAction { key, action_id }. OverlayHandle::take_actions() drains only its own ids (FIFO); clear() purges its key's pending ids; the static take_actions is demoted to sweep_orphan_actions() (dead-owner ids only). app's drain logs orphans. clear_all_global already clears the queue (SEC-007). Reconciliations (lead brief): 1. SEC-004/F-1 — claim_input is gated on no active secret prompt at the app site, and render_global no longer strips keyboard at all (the gated claim_input is the sole keyboard block); release-beneath-focus is button-less only (stop_text_input clears ANY focus, which would steal a button's focus otherwise). 2. QA-002 — claim_input strips Space (and render_global's removal means the kittest keyboard path runs through claim_input). TC-OVL-044 now also presses Space. 3. QA-003 — render_card/render_buttons take trap_focus; the instance Component::show passes false so it never seizes the host screen's focus or installs the lock. Rest of the list: - SEC-002: overlay dim/sink/card raised to Order::Foreground (above ComboBox / autocomplete / SelectionDialog popups); passphrase modal also raised to Foreground so it stays above the overlay (R-1, TC-OVL-048). - F-3/4/7: ButtonStyle { Primary, Secondary }, with_secondary_button on OverlayConfig/OverlayHandle/instance, ConfirmationDialog-style right_to_left layout (primary right, secondary left). - SEC-005: corrected the Send+Sync note to the real invariant (UI-thread-only ops). - F-6: Elapsed uses a named placeholder. SEC-006: log-content doc note on show_global. - QA-007: instance clear() makes the empty-response path reachable. - QA-008: TC-OVL-013b asserts elapsed >= 2s; TC-OVL-021 also bounds vertically. Tests: un-ignored qa_buttonless probe; new inline tests (watchdog threshold/clock-reset/ one-shot, keyed FIFO/isolation/orphan-sweep, QA-007); new kittest reconciliations (render_global keeps keyboard for the prompt; instance show leaves host focus navigable). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 18 +- src/ui/components/passphrase_modal.rs | 5 + src/ui/components/progress_overlay.rs | 607 +++++++++++++++++++++----- tests/kittest/progress_overlay.rs | 214 ++++++--- 4 files changed, 668 insertions(+), 176 deletions(-) diff --git a/src/app.rs b/src/app.rs index 129b02e64..9b3f1c69e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1139,19 +1139,19 @@ impl AppState { } } - /// Drain pending overlay button-action clicks queued by - /// [`OverlayConfig::with_button`]/[`OverlayHandle::with_button`]. The overlay - /// is UI-only and never auto-lowers on a click — the app loop owns dispatch. - /// Buttons are a generic facility (no built-in Cancel): a screen that raised - /// the overlay via the global path registers its own action ids here. No - /// production overlay attaches a button in v1, so there is no registered - /// handler yet; unrecognised ids are logged and dropped rather than dispatched. + /// Sweep orphaned overlay button-action clicks (A-3): ids whose owning + /// overlay entry is no longer on the stack (the owner cleared or dropped its + /// handle without draining). Screens own dispatch — they drain **their own** + /// clicks at the top of `ui()` via [`OverlayHandle::take_actions`] and match + /// their own colon-namespaced ids, including their own cancellation. This loop + /// only reclaims truly-orphaned ids so they can't accumulate in `ctx.data`; it + /// can never race or pre-empt a live owner, so its position here is safe. fn drain_overlay_actions(&mut self, ctx: &egui::Context) { - for action_id in ProgressOverlay::take_actions(ctx) { + for action_id in ProgressOverlay::sweep_orphan_actions(ctx) { tracing::warn!( target = "ui::overlay", action_id = %action_id, - "Unhandled overlay action id — dropping (no registered handler)" + "Overlay action received for an overlay that is no longer active — dropping" ); } } diff --git a/src/ui/components/passphrase_modal.rs b/src/ui/components/passphrase_modal.rs index deb68119e..90ee49e12 100644 --- a/src/ui/components/passphrase_modal.rs +++ b/src/ui/components/passphrase_modal.rs @@ -139,6 +139,11 @@ pub fn passphrase_modal( let window_response = egui::Window::new(config.window_title) .collapsible(false) .resizable(false) + // Render on Order::Foreground so the prompt stays above the blocking + // progress overlay (also Foreground, but drawn earlier this frame) — the + // overlay must never cover a secret prompt it triggered (R-1, SEC-002). + // Created after the overlay and focus-raised, so it wins within Foreground. + .order(egui::Order::Foreground) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .open(&mut window_is_open) .frame(egui::Frame { diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index 5e6a8bd2a..51b5926ec 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -14,12 +14,14 @@ //! ## Buttons are a generic facility (no built-in Cancel) //! //! The overlay has **no** Cancel concept. A caller attaches a generic button -//! with [`OverlayConfig::with_button`] / [`OverlayHandle::with_button`], picking -//! its own opaque action id and label. Clicking the button enqueues that action -//! id; the overlay does **not** auto-lower. The owning screen drains action ids -//! via [`ProgressOverlay::take_actions`] (FIFO) and decides what to do — -//! including running its own cancellation logic if it chose to label a button -//! "Cancel". +//! with [`OverlayConfig::with_button`] / [`OverlayHandle::with_button`] (or the +//! `*_secondary_button` variants), picking its own opaque action id and label. +//! Clicking the button enqueues that action id, keyed by the owning entry; the +//! overlay does **not** auto-lower. The owning screen drains **its own** ids via +//! [`OverlayHandle::take_actions`] (FIFO) at the top of its `ui()` and decides +//! what to do — including running its own cancellation logic if it labelled a +//! button "Cancel". The app loop only sweeps orphaned ids via +//! [`ProgressOverlay::sweep_orphan_actions`]. //! //! ## Two render paths (mirrors `MessageBanner`) //! @@ -62,6 +64,11 @@ const OVERLAY_CARD_ID: &str = "__global_progress_overlay_card"; /// elapsed readout and a reassurance line. Visual only — never auto-aborts. const STUCK_OVERLAY_THRESHOLD: Duration = Duration::from_secs(30); +/// After this long *without progress* on the topmost request, escalate the +/// reassurance copy and fire the one-shot developer watchdog (A-1). A leaked +/// handle (C1) or an un-bounded op (C2) is the usual cause — both are bugs. +const STUCK_OVERLAY_WATCHDOG_THRESHOLD: Duration = Duration::from_secs(120); + /// Diameter of the indeterminate spinner inside the card. const SPINNER_SIZE: f32 = 32.0; /// Card minimum width so short content still reads as a deliberate dialog. @@ -71,9 +78,13 @@ const CARD_MAX_WIDTH: f32 = 420.0; /// Description scrolls inside the card past this height (FR-6: never off-screen). const DESCRIPTION_MAX_HEIGHT: f32 = 160.0; -/// Reassurance line revealed once the stuck threshold passes. +/// Reassurance line revealed once the soft 30 s stuck threshold passes. const STUCK_REASSURANCE: &str = "This is taking longer than usual."; +/// Escalated reassurance shown once the 120 s no-progress watchdog trips, +/// replacing (not stacking with) [`STUCK_REASSURANCE`]. +const STUCK_WATCHDOG_REASSURANCE: &str = "This is taking much longer than expected. The operation is still running — please keep the app open."; + /// Monotonic counter for generating unique overlay keys. static OVERLAY_KEY_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -83,12 +94,34 @@ fn next_overlay_key() -> u64 { OVERLAY_KEY_COUNTER.fetch_add(1, Ordering::Relaxed) } +/// Visual tier of an overlay button (F-3/F-4/F-7). Styling and placement only — +/// both tiers are generic and carry no built-in semantics (there is no Cancel). +#[derive(Clone, Copy, PartialEq, Eq)] +enum ButtonStyle { + /// Accent fill; hugs the right edge — the affirmative / continue action. + Primary, + /// Muted fill; sits to the left of the primary — e.g. a caller's "cancel". + Secondary, +} + /// One generic action button on the overlay card. The caller owns both the -/// `label` (an i18n unit) and the `action_id` enqueued on click. +/// `label` (an i18n unit, user-visible and logged) and the opaque `action_id` +/// enqueued on click. `style` controls appearance and placement only. #[derive(Clone)] struct OverlayButton { label: String, action_id: String, + style: ButtonStyle, +} + +impl OverlayButton { + fn new(id: impl fmt::Display, label: impl fmt::Display, style: ButtonStyle) -> Self { + Self { + label: label.to_string(), + action_id: id.to_string(), + style, + } + } } /// Snapshot of the content fields logged for change-detection: the description @@ -111,21 +144,31 @@ struct OverlayState { logged: bool, /// Last content logged, so a description/step update logs exactly once. logged_content: Option, + /// Last time the content (description/step) actually changed. The no-progress + /// watchdog (A-1) measures from here, so a legitimately advancing multi-step + /// flow never trips it while a genuinely wedged single step does. + last_progress_at: Instant, + /// Set once the no-progress watchdog has fired its one-shot dev-error (A-1), + /// so the error logs exactly once, never per frame (NFR-5). + watchdog_logged: bool, /// Set once focus has been placed on the first button (focus trap). focus_requested: bool, } impl OverlayState { fn new(key: u64, description: Option, config: &OverlayConfig) -> Self { + let now = Instant::now(); Self { key, description, step: config.step, buttons: config.buttons.clone(), show_elapsed: config.show_elapsed, - created_at: Instant::now(), + created_at: now, logged: false, logged_content: None, + last_progress_at: now, + watchdog_logged: false, focus_requested: false, } } @@ -165,14 +208,31 @@ impl OverlayConfig { self } - /// Add a generic action button. The caller owns the opaque `id` enqueued on - /// click and the `label` shown on the button; clicking does not lower the - /// overlay — the owning screen drains the id and decides what to do. + /// Add a **primary** action button (accent fill, hugs the right edge). The + /// caller owns the opaque `id` enqueued on click and the `label` shown on the + /// button; clicking does not lower the overlay — the owning screen drains the + /// id and decides what to do. + /// + /// Buttons render right-to-left in the order added: primaries hug the right + /// edge, secondaries sit to their left. SEC-006: `id` and `label` are + /// user-visible and logged — never pass secrets or PII. pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { - self.buttons.push(OverlayButton { - label: label.to_string(), - action_id: id.to_string(), - }); + self.buttons + .push(OverlayButton::new(id, label, ButtonStyle::Primary)); + self + } + + /// Add a **secondary** action button (muted fill, sits left of the primary). + /// Same generic semantics as [`with_button`](Self::with_button) — only the + /// styling and placement differ; there is no built-in Cancel. SEC-006: `id` + /// and `label` are user-visible and logged — never pass secrets or PII. + pub fn with_secondary_button( + mut self, + id: impl fmt::Display, + label: impl fmt::Display, + ) -> Self { + self.buttons + .push(OverlayButton::new(id, label, ButtonStyle::Secondary)); self } } @@ -182,9 +242,14 @@ impl OverlayConfig { /// content can be updated without losing the reference. Methods are no-ops /// returning `None` once the entry is gone. /// -/// INTENTIONAL(SEC-004): `OverlayHandle` is Send+Sync because `egui::Context` is -/// Send+Sync with internal locking. Acceptable for a single-threaded UI app; -/// egui's own thread-safety guarantees apply. +/// INTENTIONAL(SEC-005): `OverlayHandle` is `Send + Sync` only because it holds +/// an `egui::Context` (itself `Send + Sync` via internal locking). That does NOT +/// make handle operations thread-safe to interleave: every method reads-modifies- +/// writes the global `ctx.data` overlay slot non-atomically, so the real +/// invariant is that all handle operations run on the single egui UI/update +/// thread (where the overlay is shown, mutated, and cleared). The `Send + Sync` +/// derivation is incidental to `egui::Context`'s bounds, not a claim of +/// cross-thread correctness. #[derive(Clone)] pub struct OverlayHandle { ctx: egui::Context, @@ -225,17 +290,54 @@ impl OverlayHandle { self.mutate(|s| s.step = None) } - /// Attach a generic action button — opaque `id` enqueued on click + `label` - /// shown verbatim. Returns `None` if the entry is gone. + /// Attach a **primary** action button (accent fill, right edge) — opaque `id` + /// enqueued on click + `label` shown verbatim. Buttons render right-to-left in + /// the order added. SEC-006: `id`/`label` are user-visible and logged — never + /// pass secrets or PII. Returns `None` if the entry is gone. pub fn with_button(&self, id: impl fmt::Display, label: impl fmt::Display) -> Option<&Self> { - let action_id = id.to_string(); - let label = label.to_string(); - self.mutate(|s| { - s.buttons.push(OverlayButton { label, action_id }); - }) + let button = OverlayButton::new(id, label, ButtonStyle::Primary); + self.mutate(|s| s.buttons.push(button)) + } + + /// Attach a **secondary** action button (muted fill, left of the primary). + /// Same generic semantics as [`with_button`](Self::with_button). SEC-006: + /// `id`/`label` are user-visible and logged. Returns `None` if the entry is gone. + pub fn with_secondary_button( + &self, + id: impl fmt::Display, + label: impl fmt::Display, + ) -> Option<&Self> { + let button = OverlayButton::new(id, label, ButtonStyle::Secondary); + self.mutate(|s| s.buttons.push(button)) + } + + /// Drain (FIFO) and remove the action ids enqueued by **this handle's** + /// button clicks, leaving other overlay entries' actions untouched (A-3). + /// + /// The owning screen calls this at the top of its own `ui()` each frame and + /// matches its own colon-namespaced ids (e.g. `shielded:build:cancel`), + /// running whatever logic it owns — including its own cancellation. Returns + /// an empty `Vec` when this handle has no pending clicks (or is already gone). + pub fn take_actions(&self) -> Vec { + let mut queue = get_overlay_actions(&self.ctx); + let mut mine = Vec::new(); + queue.retain(|a| { + if a.key == self.key { + mine.push(a.action_id.clone()); + false + } else { + true + } + }); + if !mine.is_empty() { + set_overlay_actions(&self.ctx, queue); + } + mine } - /// Dismiss only this handle's entry. The overlay lowers when the stack empties. + /// Dismiss only this handle's entry, and purge any of its still-pending action + /// ids so a normal dismiss leaves nothing for the orphan-sweeper (A-3). The + /// overlay lowers when the stack empties. pub fn clear(self) { let mut stack = get_overlay_state(&self.ctx); let before = stack.len(); @@ -244,10 +346,20 @@ impl OverlayHandle { debug!(key = self.key, "Blocking progress overlay dismissed"); } set_overlay_state(&self.ctx, stack); + + let mut queue = get_overlay_actions(&self.ctx); + let kept = queue.len(); + queue.retain(|a| a.key != self.key); + if queue.len() != kept { + set_overlay_actions(&self.ctx, queue); + } } - /// Find this handle's entry, apply `f`, and write the stack back. Resets the - /// content-log marker so the next render logs the change once. + /// Find this handle's entry, apply `f`, and write the stack back. The next + /// `log_overlay_state` detects the content change (it compares + /// `(description, step)`), logs the update once, and bumps `last_progress_at` + /// for the no-progress watchdog — so this method intentionally does not touch + /// `logged_content` itself. fn mutate(&self, f: impl FnOnce(&mut OverlayState)) -> Option<&Self> { let mut stack = get_overlay_state(&self.ctx); let entry = stack.iter_mut().find(|s| s.key == self.key)?; @@ -347,23 +459,47 @@ impl ProgressOverlay { self } - /// Add a generic action button. The caller owns the opaque `id` surfaced + /// Add a **primary** action button. The caller owns the opaque `id` surfaced /// through [`ProgressOverlayResponse`] on click and the `label` shown on it. pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { if let Some(state) = &mut self.state { - state.buttons.push(OverlayButton { - label: label.to_string(), - action_id: id.to_string(), - }); + state + .buttons + .push(OverlayButton::new(id, label, ButtonStyle::Primary)); } self } + /// Add a **secondary** action button (left of the primary). Same generic + /// semantics as [`with_button`](Self::with_button). + pub fn with_secondary_button( + mut self, + id: impl fmt::Display, + label: impl fmt::Display, + ) -> Self { + if let Some(state) = &mut self.state { + state + .buttons + .push(OverlayButton::new(id, label, ButtonStyle::Secondary)); + } + self + } + + /// Clear this instance so [`Component::show`] renders nothing and returns the + /// empty response (QA-007: makes the `state == None` path reachable via the + /// public API). Idempotent. + pub fn clear(&mut self) { + self.state = None; + } + // ── Global (ctx.data) path ────────────────────────────────────────────── /// Raise the overlay and return its handle. Non-blocking: only writes /// `ctx.data`. The `description` argument is used unless `config` already /// carries one. + /// + /// SEC-006: the `description` (and any button `label`/`id`) is user-visible + /// and written to logs on show — never pass secrets, passphrases, or PII. pub fn show_global( ctx: &egui::Context, description: impl fmt::Display, @@ -402,13 +538,29 @@ impl ProgressOverlay { !get_overlay_state(ctx).is_empty() } - /// Drain the action-id queue (FIFO) for the app loop to dispatch. - pub fn take_actions(ctx: &egui::Context) -> Vec { - let actions = get_overlay_actions(ctx); - if !actions.is_empty() { - set_overlay_actions(ctx, Vec::new()); + /// Orphan-sweeper (A-3): drain and return only action ids whose owning + /// overlay entry is **no longer on the stack** — i.e. the owner cleared or + /// dropped its handle without draining. Live owners' actions are left + /// untouched, so this can never race or pre-empt a screen that owns an active + /// overlay, regardless of call order. The app loop calls this and logs each + /// truly-orphaned id; screens drain their own via [`OverlayHandle::take_actions`]. + pub fn sweep_orphan_actions(ctx: &egui::Context) -> Vec { + let live: std::collections::HashSet = + get_overlay_state(ctx).iter().map(|s| s.key).collect(); + let mut queue = get_overlay_actions(ctx); + let mut orphans = Vec::new(); + queue.retain(|a| { + if live.contains(&a.key) { + true + } else { + orphans.push(a.action_id.clone()); + false + } + }); + if !orphans.is_empty() { + set_overlay_actions(ctx, queue); } - actions + orphans } /// Clear every entry — used on network switch alongside the banner reset. @@ -442,11 +594,19 @@ impl ProgressOverlay { /// buttoned case (post-T7) re-grants its own buttons' navigation via the /// focus-lock filter in `render_buttons`. pub fn claim_input(ctx: &egui::Context) { - if !Self::has_global(ctx) { + let stack = get_overlay_state(ctx); + let Some(top) = stack.last() else { return; + }; + // Release beneath focus ONLY for a button-less block — it has no widget of + // its own to hold focus, so a focused field beneath would keep its caret. + // A buttoned block keeps its button focused (`render_buttons` manages the + // focus + lock), so do NOT clear focus here — `stop_text_input` clears the + // *currently focused* widget regardless of type, which would steal the + // button's focus every frame. + if top.buttons.is_empty() { + ctx.memory_mut(|m| m.stop_text_input()); } - // Only releases text-edit focus, so an overlay button keeps its focus. - ctx.memory_mut(|m| m.stop_text_input()); ctx.input_mut(|i| { i.events.retain(|e| { !matches!( @@ -478,46 +638,48 @@ impl ProgressOverlay { return; }; - // Belt-and-suspenders end-of-frame filter. The primary, gated block is - // `claim_input` at frame start (which also yields to an active secret - // prompt); this second pass swallows Esc/Tab/Enter so they cannot dismiss - // the overlay, move focus beneath it, or activate a focused button. - // TODO(QA blocker #2): this pass is NOT gated on an active secret prompt, - // so it still swallows Esc while a passphrase modal is up above the - // overlay. Once `claim_input` is proven sufficient, remove this filter and - // route the keyboard tests through `claim_input` so the gated path is the - // only swallow — then a secret prompt's Esc-to-cancel survives. - ctx.input_mut(|i| { - i.consume_key(egui::Modifiers::NONE, egui::Key::Escape); - i.events.retain(|e| { - !matches!( - e, - egui::Event::Key { - key: egui::Key::Tab | egui::Key::Enter, - pressed: true, - .. - } - ) - }); - }); - + // NB: render_global does NO keyboard stripping. All key/text claiming + // happens in `claim_input` at frame start, which the app loop gates on no + // active secret prompt (SEC-004/F-1) — a passphrase modal rendered above + // the overlay must keep Enter/Esc/Tab. Stripping here would be both too + // late (end-of-frame) and ungated (would re-break the prompt). The buttoned + // case additionally relies on the focus-lock filter set in `render_buttons`. let elapsed = top.created_at.elapsed(); let stuck = stuck_reveal(elapsed); let show_elapsed = top.show_elapsed || stuck; + let key = top.key; + // Logs once on show / once per content change, and bumps `last_progress_at` + // on a content change so the no-progress watchdog measures true stalls. log_overlay_state(top); + // No-progress watchdog (A-1): once the topmost request has shown no + // progress for over two minutes, escalate the reassurance copy and fire a + // one-shot dev-error — almost always a leaked handle (C1) or an un-bounded + // operation (C2), i.e. a bug. No panic: a time-based assert would be flaky. + let watchdog = watchdog_tripped(top.last_progress_at); + if watchdog && !top.watchdog_logged { + top.watchdog_logged = true; + tracing::error!( + key, + "Blocking overlay has shown no progress for over 2 minutes — \ + likely a leaked handle or an un-bounded operation" + ); + } + let dark_mode = ctx.style().visuals.dark_mode; let rect = ctx.content_rect(); - // Dim + pointer sink share one Middle-order layer so the dim is always - // behind the card (a later Middle area). The sink consumes pointer - // events; its own clicks are ignored, so a backdrop click never dismisses. + // SEC-002: the dim + pointer sink + card render on Order::Foreground so + // they sit above Foreground popups (egui ComboBox, address autocomplete, + // SelectionDialog) that would otherwise float over a Middle-order block and + // stay clickable. The secret prompt is raised to match and rendered later + // (focus-raised), so it still wins above the overlay (R-1, TC-OVL-048). let sink_layer = - egui::LayerId::new(egui::Order::Middle, egui::Id::new(OVERLAY_DIM_SINK_ID)); + egui::LayerId::new(egui::Order::Foreground, egui::Id::new(OVERLAY_DIM_SINK_ID)); ctx.layer_painter(sink_layer) .rect_filled(rect, 0.0, DashColors::modal_overlay()); egui::Area::new(egui::Id::new(OVERLAY_DIM_SINK_ID)) - .order(egui::Order::Middle) + .order(egui::Order::Foreground) .fixed_pos(rect.min) .show(ctx, |ui| { ui.allocate_response(rect.size(), egui::Sense::click_and_drag()); @@ -525,19 +687,28 @@ impl ProgressOverlay { let mut clicked = None; egui::Area::new(egui::Id::new(OVERLAY_CARD_ID)) - .order(egui::Order::Middle) + .order(egui::Order::Foreground) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) .show(ctx, |ui| { - clicked = render_card(ui, top, dark_mode, elapsed, show_elapsed, stuck); + clicked = render_card( + ui, + top, + dark_mode, + elapsed, + show_elapsed, + stuck, + watchdog, + true, + ); }); - // The click does not lower the overlay — the app loop drains the queue - // via `take_actions` and the owning screen decides what to do. + // The click does not lower the overlay — the owning screen drains its own + // ids via `OverlayHandle::take_actions`; the app loop only sweeps orphans. if let Some(action_id) = clicked { - push_overlay_action(ctx, &action_id); + push_overlay_action(ctx, key, &action_id); } - if show_elapsed { + if show_elapsed || watchdog { ctx.request_repaint_after(Duration::from_secs(1)); } @@ -560,8 +731,22 @@ impl Component for ProgressOverlay { let elapsed = state.created_at.elapsed(); let stuck = stuck_reveal(elapsed); let show_elapsed = state.show_elapsed || stuck; - - let clicked = render_card(ui, state, dark_mode, elapsed, show_elapsed, stuck); + let watchdog = watchdog_tripped(state.last_progress_at); + + // QA-003: the instance path renders the card WITHOUT seizing global focus + // or installing the focus-lock filter (`trap_focus = false`). That trap + // belongs to the full-window global block; an inline, non-blocking widget + // must leave the host screen's Tab/arrow/Esc navigation intact. + let clicked = render_card( + ui, + state, + dark_mode, + elapsed, + show_elapsed, + stuck, + watchdog, + false, + ); if let Some(action_id) = &clicked { self.last_action = Some(action_id.clone()); } @@ -593,11 +778,20 @@ fn empty_overlay_response(ui: &mut egui::Ui) -> InnerResponse bool { elapsed >= STUCK_OVERLAY_THRESHOLD } +/// Whether the no-progress watchdog has tripped: over [`STUCK_OVERLAY_WATCHDOG_THRESHOLD`] +/// has elapsed since the last content change (A-1). Like [`stuck_reveal`], takes +/// the measured instant as a parameter so it is unit-testable. +fn watchdog_tripped(last_progress: Instant) -> bool { + last_progress.elapsed() >= STUCK_OVERLAY_WATCHDOG_THRESHOLD +} + /// Whether a `(current, total)` step pair is meaningful enough to render. Hides /// nonsense pairs (`0 of 0`, `4 of 3`, `0 of 5`) rather than painting them. fn step_is_renderable(current: u32, total: u32) -> bool { @@ -617,6 +811,9 @@ fn log_overlay_state(state: &mut OverlayState) { ); } else if state.logged_content.as_ref() != Some(&content) { state.logged_content = Some(content); + // Real progress: reset the no-progress watchdog clock (A-1) so a + // legitimately advancing flow never trips it. + state.last_progress_at = Instant::now(); debug!( description = ?state.description, step = ?state.step, @@ -629,6 +826,7 @@ fn log_overlay_state(state: &mut OverlayState) { /// description, optional elapsed/reassurance, optional button row. Returns the /// action id of a button clicked this frame, if any. Shared by the instance /// [`Component::show`] and the global [`ProgressOverlay::render_global`] paths. +#[allow(clippy::too_many_arguments)] fn render_card( ui: &mut egui::Ui, state: &mut OverlayState, @@ -636,6 +834,8 @@ fn render_card( elapsed: Duration, show_elapsed: bool, stuck: bool, + watchdog: bool, + trap_focus: bool, ) -> Option { let mut clicked = None; egui::Frame::new() @@ -690,13 +890,22 @@ fn render_card( if show_elapsed { ui.add_space(Spacing::XS); + let seconds = elapsed.as_secs(); ui.label( - egui::RichText::new(format!("Elapsed: {}s", elapsed.as_secs())) + egui::RichText::new(format!("Elapsed: {seconds}s")) .color(DashColors::text_secondary(dark_mode)), ); } - if stuck { + // The 120 s watchdog escalation replaces (never stacks with) the + // soft 30 s reassurance line (A-1). + if watchdog { + ui.add_space(Spacing::XS); + ui.label( + egui::RichText::new(STUCK_WATCHDOG_REASSURANCE) + .color(DashColors::text_secondary(dark_mode)), + ); + } else if stuck { ui.add_space(Spacing::XS); ui.label( egui::RichText::new(STUCK_REASSURANCE) @@ -706,26 +915,53 @@ fn render_card( if !state.buttons.is_empty() { ui.add_space(Spacing::MD); - clicked = render_buttons(ui, state); + clicked = render_buttons(ui, state, dark_mode, trap_focus); } }); }); clicked } -/// Render the generic button row left-to-right in insertion order. Returns the -/// clicked button's action id, if any. The first button is the focus stop on -/// raise, and a focus lock filter traps Tab/arrows/Esc on it so keyboard -/// navigation cannot escape to a widget beneath the block. Clicks never lower -/// the overlay. -fn render_buttons(ui: &mut egui::Ui, state: &mut OverlayState) -> Option { - let want_focus = !state.focus_requested; +/// Render the action button row. Layout mirrors `ConfirmationDialog`: a +/// `right_to_left` row so the **primary** action hugs the RIGHT edge and any +/// **secondary** buttons sit to its LEFT; a single button hugs the right edge. +/// Within each tier, buttons render in the order they were added. Returns the +/// clicked button's action id, if any. Clicks never lower the overlay. +/// +/// `trap_focus` is `true` only for the global full-window block: the first +/// button is focused on raise and a focus-lock filter traps Tab/arrows/Esc on it +/// so keyboard navigation cannot escape to a widget beneath the block. The +/// instance [`Component`] path passes `false` (QA-003) so an inline, non-blocking +/// widget never seizes the host screen's focus. +fn render_buttons( + ui: &mut egui::Ui, + state: &mut OverlayState, + dark_mode: bool, + trap_focus: bool, +) -> Option { + let want_focus = trap_focus && !state.focus_requested; let mut clicked = None; let mut focus_stop = None; - ui.horizontal(|ui| { - for button in &state.buttons { - let response = ComponentStyles::add_primary_button(ui, &button.label); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Primaries first → rightmost (accent); secondaries after → to their left. + let ordered = state + .buttons + .iter() + .filter(|b| b.style == ButtonStyle::Primary) + .chain( + state + .buttons + .iter() + .filter(|b| b.style == ButtonStyle::Secondary), + ); + for button in ordered { + let response = match button.style { + ButtonStyle::Primary => ComponentStyles::add_primary_button(ui, &button.label), + ButtonStyle::Secondary => { + ComponentStyles::add_secondary_button(ui, &button.label, dark_mode) + } + }; if focus_stop.is_none() { focus_stop = Some(response.id); if want_focus { @@ -738,7 +974,7 @@ fn render_buttons(ui: &mut egui::Ui, state: &mut OverlayState) -> Option } }); - if let Some(id) = focus_stop { + if trap_focus && let Some(id) = focus_stop { // Trap keyboard focus on the block. egui resolves Tab/arrow navigation in // `begin_pass` (before this code runs), so filtering those key events here // is too late — only a focus lock filter on the focused widget keeps @@ -775,25 +1011,38 @@ fn set_overlay_state(ctx: &egui::Context, stack: Vec) { } } +/// A pending button click, scoped to the overlay entry that owns it (A-3). The +/// `key` lets the owning [`OverlayHandle`] drain only its own ids while the +/// orphan-sweeper reclaims ids whose owner is gone. +#[derive(Clone)] +struct OverlayAction { + key: u64, + action_id: String, +} + /// Reads the pending overlay-action queue (FIFO) from egui context data. -fn get_overlay_actions(ctx: &egui::Context) -> Vec { - ctx.data(|d| d.get_temp::>(egui::Id::new(OVERLAY_ACTIONS_ID))) +fn get_overlay_actions(ctx: &egui::Context) -> Vec { + ctx.data(|d| d.get_temp::>(egui::Id::new(OVERLAY_ACTIONS_ID))) .unwrap_or_default() } /// Writes the pending overlay-action queue. Removes the slot when empty. -fn set_overlay_actions(ctx: &egui::Context, actions: Vec) { +fn set_overlay_actions(ctx: &egui::Context, actions: Vec) { if actions.is_empty() { - ctx.data_mut(|d| d.remove::>(egui::Id::new(OVERLAY_ACTIONS_ID))); + ctx.data_mut(|d| d.remove::>(egui::Id::new(OVERLAY_ACTIONS_ID))); } else { ctx.data_mut(|d| d.insert_temp(egui::Id::new(OVERLAY_ACTIONS_ID), actions)); } } -/// Appends an action id to the queue. Called from the renderer on a button click. -fn push_overlay_action(ctx: &egui::Context, action_id: &str) { +/// Appends an action id (scoped to its owning entry's `key`) to the queue. +/// Called from `render_global` on a button click. +fn push_overlay_action(ctx: &egui::Context, key: u64, action_id: &str) { let mut queue = get_overlay_actions(ctx); - queue.push(action_id.to_string()); + queue.push(OverlayAction { + key, + action_id: action_id.to_string(), + }); set_overlay_actions(ctx, queue); } @@ -947,14 +1196,62 @@ mod tests { assert!(!ProgressOverlay::has_global(&ctx)); } + /// A-3 — a handle drains its **own** clicks FIFO then empties, and the + /// orphan-sweeper sees nothing while the owner is still live. #[test] - fn take_actions_drains_fifo_then_empties() { + fn handle_take_actions_drains_own_fifo_then_empties() { let ctx = egui::Context::default(); - assert!(ProgressOverlay::take_actions(&ctx).is_empty()); - push_overlay_action(&ctx, "first"); - push_overlay_action(&ctx, "second"); - assert_eq!(ProgressOverlay::take_actions(&ctx), vec!["first", "second"]); - assert!(ProgressOverlay::take_actions(&ctx).is_empty()); + let handle = ProgressOverlay::show_global_spinner_only(&ctx); + assert!(handle.take_actions().is_empty()); + + push_overlay_action(&ctx, handle.key, "first"); + push_overlay_action(&ctx, handle.key, "second"); + + // The owner is live, so the orphan-sweeper must not touch its ids. + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); + + assert_eq!(handle.take_actions(), vec!["first", "second"]); + assert!(handle.take_actions().is_empty()); + } + + /// A-3 — two stacked overlays: a click keyed to B is drained only by B; A + /// never steals it (no cross-owner theft). + #[test] + fn keyed_actions_isolate_owners() { + let ctx = egui::Context::default(); + let a = ProgressOverlay::show_global(&ctx, "A.", OverlayConfig::default()); + let b = ProgressOverlay::show_global(&ctx, "B.", OverlayConfig::default()); + push_overlay_action(&ctx, b.key, "b:action"); + + assert!(a.take_actions().is_empty(), "A must not see B's click"); + assert_eq!(b.take_actions(), vec!["b:action"]); + assert!(b.take_actions().is_empty()); + } + + /// A-3 — a handle dropped without draining leaves its id reachable only via + /// `sweep_orphan_actions`; `clear()` instead leaves nothing for the sweeper. + #[test] + fn orphan_sweeper_reclaims_only_dead_owner_ids() { + let ctx = egui::Context::default(); + + // Owner clears normally → its pending id is purged, sweeper finds nothing. + let cleared = ProgressOverlay::show_global_spinner_only(&ctx); + push_overlay_action(&ctx, cleared.key, "cleared:id"); + cleared.clear(); + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); + + // Owner dropped without draining → its id is orphaned and swept once. + let dropped = ProgressOverlay::show_global_spinner_only(&ctx); + let dropped_key = dropped.key; + push_overlay_action(&ctx, dropped_key, "dropped:id"); + ProgressOverlay::clear_all_global(&ctx); // entry gone, id keyed to a dead owner + // Re-enqueue against the now-dead key to model the drop-without-drain race. + push_overlay_action(&ctx, dropped_key, "dropped:id"); + assert_eq!( + ProgressOverlay::sweep_orphan_actions(&ctx), + vec!["dropped:id"] + ); + assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); } /// SEC-007 — `clear_all_global` (network switch) drains the action queue too, @@ -963,14 +1260,14 @@ mod tests { #[test] fn clear_all_global_clears_action_queue() { let ctx = egui::Context::default(); - ProgressOverlay::show_global_spinner_only(&ctx); - push_overlay_action(&ctx, "shielded:build:cancel"); + let handle = ProgressOverlay::show_global_spinner_only(&ctx); + push_overlay_action(&ctx, handle.key, "shielded:build:cancel"); ProgressOverlay::clear_all_global(&ctx); assert!(!ProgressOverlay::has_global(&ctx), "state stack is cleared"); assert!( - ProgressOverlay::take_actions(&ctx).is_empty(), + ProgressOverlay::sweep_orphan_actions(&ctx).is_empty(), "SEC-007: the action queue must be cleared on a network switch" ); } @@ -1148,4 +1445,94 @@ mod tests { // recorded on the instance only when they occur. assert!(overlay.current_value().is_none()); } + + /// A-1 — the no-progress watchdog trips only past its threshold (clock seam). + #[test] + fn watchdog_tripped_only_past_threshold() { + let now = Instant::now(); + assert!(!watchdog_tripped(now)); + if let Some(old) = + now.checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(1)) + { + assert!(watchdog_tripped(old)); + } + if let Some(recent) = + now.checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD - Duration::from_secs(1)) + { + assert!(!watchdog_tripped(recent)); + } + } + + /// A-1 — a real content change resets the no-progress clock (so a progressing + /// flow never trips the watchdog); no change leaves it untouched. + #[test] + fn log_overlay_state_bumps_progress_clock_on_content_change() { + let mut state = OverlayState::new(1, Some("a".to_string()), &OverlayConfig::default()); + state.logged = true; + state.logged_content = Some((Some("a".to_string()), None)); + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + assert!(watchdog_tripped(state.last_progress_at)); + + // Content changes → log_overlay_state bumps the clock. + state.description = Some("b".to_string()); + log_overlay_state(&mut state); + assert!( + !watchdog_tripped(state.last_progress_at), + "a content change must reset the no-progress clock" + ); + + // No change → the clock is left untouched. + let before = state.last_progress_at; + log_overlay_state(&mut state); + assert_eq!( + state.last_progress_at, before, + "no content change must not touch the clock" + ); + } + + /// A-1 — the watchdog dev-error flag flips once on render and stays set, so the + /// error logs exactly once rather than every frame (NFR-5). + #[test] + fn watchdog_flag_flips_once_via_render() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::show_global_spinner_only(&ctx); + { + let mut stack = get_overlay_state(&ctx); + let top = stack.iter_mut().find(|s| s.key == handle.key).unwrap(); + top.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + set_overlay_state(&ctx, stack); + } + let logged = |ctx: &egui::Context| { + get_overlay_state(ctx) + .iter() + .find(|s| s.key == handle.key) + .map(|s| s.watchdog_logged) + .unwrap() + }; + render_once(&ctx); + assert!(logged(&ctx), "the watchdog flag flips on render"); + render_once(&ctx); + assert!(logged(&ctx), "and stays set across frames"); + } + + /// QA-007 — the instance `clear()` makes the empty-response path reachable via + /// the public API: after clear, `show()` renders nothing and reports no value. + #[test] + fn instance_clear_reaches_empty_response() { + let ctx = egui::Context::default(); + let mut overlay = ProgressOverlay::new().with_description("Working."); + overlay.clear(); + let _ = ctx.run(egui::RawInput::default(), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + let response = overlay.show(ui).inner; + assert!(!response.has_changed()); + assert!(response.changed_value().is_none()); + }); + }); + assert!(overlay.current_value().is_none()); + } } diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index f37b80d66..204b44833 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -40,11 +40,13 @@ use egui_kittest::kittest::Queryable; const SPINNER_ROLE: egui::accesskit::Role = egui::accesskit::Role::ProgressIndicator; -/// Build a harness whose per-frame closure renders only the overlay. +/// Build a harness whose per-frame closure mirrors `AppState::update`: claim +/// input at frame start (the sole keyboard/text block), then render the overlay. fn overlay_harness() -> Harness<'static> { Harness::builder() .with_size(egui::vec2(420.0, 360.0)) .build_ui(|ui| { + ProgressOverlay::claim_input(ui.ctx()); ProgressOverlay::render_global(ui.ctx()); }) } @@ -269,12 +271,14 @@ fn tc_ovl_013b_elapsed_on_counts_up() { harness.step(); assert!(harness.query_by_label("Elapsed: 0s").is_some()); - // Real wall-clock elapsed (Instant-based) — counts up, never down. - std::thread::sleep(Duration::from_millis(1100)); + // Real wall-clock elapsed (Instant-based) — counts up, never down. QA-008: + // assert it advanced to at least 2s, not merely past 0s. + std::thread::sleep(Duration::from_millis(2100)); harness.step(); assert!( - harness.query_by_label("Elapsed: 0s").is_none(), - "the readout advanced past 0s" + harness.query_by_label("Elapsed: 0s").is_none() + && harness.query_by_label("Elapsed: 1s").is_none(), + "the readout advanced to at least 2s" ); assert!( harness.query_by_label_contains("Elapsed:").is_some(), @@ -376,6 +380,11 @@ fn tc_ovl_021_long_description_within_bounds() { rect.min.x >= -1.0 && rect.max.x <= 301.0, "description stays within the window horizontally: {rect:?}" ); + // QA-008: also bound it vertically inside the 400px-tall window. + assert!( + rect.min.y >= -1.0 && rect.max.y <= 401.0, + "description stays within the window vertically: {rect:?}" + ); } /// TC-OVL-022 — spinner-only overlay is valid with no text, counter, or button. @@ -396,11 +405,11 @@ fn tc_ovl_022_spinner_only_valid() { #[test] fn tc_ovl_023_no_buttons_pure_block() { let mut harness = overlay_harness(); - let _handle = + let handle = ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); harness.step(); assert!(harness.query_by_label("Cancel").is_none()); - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(handle.take_actions().is_empty()); assert!(ProgressOverlay::has_global(&harness.ctx)); } @@ -410,7 +419,7 @@ fn tc_ovl_023_no_buttons_pure_block() { #[test] fn tc_ovl_024_button_click_enqueues_action() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Working.", OverlayConfig::new().with_button("overlay.cancel", "Cancel"), @@ -424,10 +433,8 @@ fn tc_ovl_024_button_click_enqueues_action() { harness.get_by_label("Cancel").click(); harness.step(); - assert_eq!( - ProgressOverlay::take_actions(&harness.ctx), - vec!["overlay.cancel".to_string()] - ); + // A-3: the owning handle drains its own click. + assert_eq!(handle.take_actions(), vec!["overlay.cancel".to_string()]); // The click does not auto-dismiss — only the app loop lowers it. assert!(ProgressOverlay::has_global(&harness.ctx)); } @@ -436,7 +443,7 @@ fn tc_ovl_024_button_click_enqueues_action() { #[test] fn tc_ovl_025_generic_button_click_enqueues_action() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Background-able.", OverlayConfig::new().with_button("overlay.run_in_bg", "Run in background"), @@ -448,23 +455,20 @@ fn tc_ovl_025_generic_button_click_enqueues_action() { harness.get_by_label("Run in background").click(); harness.step(); - assert_eq!( - ProgressOverlay::take_actions(&harness.ctx), - vec!["overlay.run_in_bg".to_string()] - ); + assert_eq!(handle.take_actions(), vec!["overlay.run_in_bg".to_string()]); assert!(ProgressOverlay::has_global(&harness.ctx)); } -/// TC-OVL-026 — the action queue drains FIFO then empties. +/// TC-OVL-026 — the owning handle drains its own clicks FIFO then empties (A-3). #[test] fn tc_ovl_026_action_queue_drains_fifo() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Two buttons.", OverlayConfig::new() - .with_button("cancel", "Cancel") - .with_button("secondary", "Secondary"), + .with_button("primary", "Primary") + .with_secondary_button("secondary", "Secondary"), ); // Settle the centered card before clicking (anchored CENTER_CENTER moves for // a couple of frames until its size is cached). @@ -472,38 +476,38 @@ fn tc_ovl_026_action_queue_drains_fifo() { harness.step(); harness.step(); - harness.get_by_label("Cancel").click(); + harness.get_by_label("Primary").click(); harness.step(); harness.get_by_label("Secondary").click(); harness.step(); assert_eq!( - ProgressOverlay::take_actions(&harness.ctx), - vec!["cancel".to_string(), "secondary".to_string()] + handle.take_actions(), + vec!["primary".to_string(), "secondary".to_string()] ); - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(handle.take_actions().is_empty()); } -/// TC-OVL-027 — generic buttons render left-to-right in insertion order. The -/// renderer uses `ComponentStyles` button helpers, never a bare `ui.button()` -/// (design-review). +/// TC-OVL-027 — F-3/F-4/F-7 layout: the primary action hugs the RIGHT edge and a +/// secondary button sits to its LEFT (mirrors `ConfirmationDialog`). The renderer +/// uses `ComponentStyles` button helpers, never a bare `ui.button()`. #[test] -fn tc_ovl_027_buttons_render_in_insertion_order() { +fn tc_ovl_027_secondary_left_primary_right() { let mut harness = overlay_harness(); let _handle = ProgressOverlay::show_global( &harness.ctx, "Two buttons.", OverlayConfig::new() - .with_button("first", "First action") - .with_button("second", "Second action"), + .with_button("primary", "Primary action") + .with_secondary_button("secondary", "Secondary action"), ); harness.step(); - let first_x = harness.get_by_label("First action").rect().center().x; - let second_x = harness.get_by_label("Second action").rect().center().x; + let second_x = harness.get_by_label("Secondary action").rect().center().x; + let first_x = harness.get_by_label("Primary action").rect().center().x; assert!( - first_x < second_x, - "the first-added button must be left of the second" + second_x < first_x, + "the secondary button must sit to the left of the primary action" ); } @@ -522,7 +526,7 @@ fn tc_ovl_028_pointer_click_beneath_blocked() { } ProgressOverlay::render_global(ui.ctx()); }); - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); harness.step(); harness.get_by_label("Increment").click(); @@ -532,7 +536,7 @@ fn tc_ovl_028_pointer_click_beneath_blocked() { 0, "widget beneath the overlay must not receive the click" ); - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(handle.take_actions().is_empty()); } /// TC-OVL-029 — keyboard input does not reach widgets beneath the overlay. @@ -544,6 +548,7 @@ fn tc_ovl_029_keyboard_beneath_blocked() { let mut harness = Harness::builder() .with_size(egui::vec2(420.0, 360.0)) .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); ProgressOverlay::render_global(ui.ctx()); @@ -570,7 +575,7 @@ fn tc_ovl_029_keyboard_beneath_blocked() { #[test] fn tc_ovl_030_backdrop_click_does_not_dismiss() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); @@ -579,7 +584,7 @@ fn tc_ovl_030_backdrop_click_does_not_dismiss() { harness.drop_at(corner); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(handle.take_actions().is_empty()); } // ── Group J — Coexistence with MessageBanner ─────────────────────────────── @@ -658,12 +663,12 @@ fn tc_ovl_037_038_handle_dismisses_only_its_own() { #[test] fn tc_ovl_039_only_topmost_actions_reachable() { let mut harness = overlay_harness(); - let _a = ProgressOverlay::show_global( + let a = ProgressOverlay::show_global( &harness.ctx, "Operation A.", OverlayConfig::new().with_button("cancel_a", "Cancel"), ); - let _b = ProgressOverlay::show_global( + let b = ProgressOverlay::show_global( &harness.ctx, "Operation B.", OverlayConfig::new().with_button("cancel_b", "Cancel"), @@ -676,10 +681,12 @@ fn tc_ovl_039_only_topmost_actions_reachable() { harness.get_by_label("Cancel").click(); harness.step(); - assert_eq!( - ProgressOverlay::take_actions(&harness.ctx), - vec!["cancel_b".to_string()], - "only the topmost request's button is reachable" + // Only the topmost (B) renders, so the click is keyed to B: B drains it, A + // never sees it (A-3 cross-owner isolation). + assert_eq!(b.take_actions(), vec!["cancel_b".to_string()]); + assert!( + a.take_actions().is_empty(), + "the lower request's handle drains nothing" ); } @@ -693,6 +700,7 @@ fn tc_ovl_041_tab_focus_trap() { let mut harness = Harness::builder() .with_size(egui::vec2(420.0, 360.0)) .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); ProgressOverlay::render_global(ui.ctx()); @@ -723,7 +731,7 @@ fn tc_ovl_041_tab_focus_trap() { #[test] fn tc_ovl_042_esc_swallowed_with_button() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Working.", OverlayConfig::new().with_button("overlay.cancel", "Cancel"), @@ -733,7 +741,7 @@ fn tc_ovl_042_esc_swallowed_with_button() { harness.key_press(egui::Key::Escape); harness.step(); assert!( - ProgressOverlay::take_actions(&harness.ctx).is_empty(), + handle.take_actions().is_empty(), "Esc must never trigger a button action" ); assert!(ProgressOverlay::has_global(&harness.ctx)); @@ -743,7 +751,7 @@ fn tc_ovl_042_esc_swallowed_with_button() { #[test] fn tc_ovl_043_esc_swallowed_without_button() { let mut harness = overlay_harness(); - let _handle = + let handle = ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); harness.step(); @@ -753,14 +761,15 @@ fn tc_ovl_043_esc_swallowed_without_button() { ProgressOverlay::has_global(&harness.ctx), "Esc must not dismiss a hard block" ); - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + assert!(handle.take_actions().is_empty()); } -/// TC-OVL-044 — Enter does not activate a focused button. +/// TC-OVL-044 — neither Enter nor Space activates a focused button (QA-002): a +/// hard block is never keyboard-activatable. #[test] -fn tc_ovl_044_enter_does_not_activate_button() { +fn tc_ovl_044_enter_and_space_do_not_activate_button() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Working.", OverlayConfig::new().with_button("overlay.cancel", "Cancel"), @@ -770,9 +779,11 @@ fn tc_ovl_044_enter_does_not_activate_button() { harness.key_press(egui::Key::Enter); harness.step(); + harness.key_press(egui::Key::Space); + harness.step(); assert!( - ProgressOverlay::take_actions(&harness.ctx).is_empty(), - "Enter must never trigger the focused button's action" + handle.take_actions().is_empty(), + "neither Enter nor Space may trigger the focused button's action" ); assert!(ProgressOverlay::has_global(&harness.ctx)); } @@ -850,7 +861,7 @@ fn tc_ovl_047_stuck_threshold_is_informational_only() { ); } -/// TC-OVL-045 (ctx.data portion) — `OptionOverlayExt::replace` swaps the entry +/// TC-OVL-045 (ctx.data portion) — `OptionOverlayExt::raise` swaps the entry /// and `take_and_clear` lowers it; the log-once flag itself is asserted in the /// inline unit tests. #[test] @@ -905,8 +916,8 @@ fn tc_ovl_050_component_instance_show_reports_click() { "current_value reports the last clicked action id" ); // The instance path does not touch the global action queue — the screen - // reads the click from the response, not via take_actions. - assert!(ProgressOverlay::take_actions(&harness.ctx).is_empty()); + // reads the click from the response, not via the handle/sweeper. + assert!(ProgressOverlay::sweep_orphan_actions(&harness.ctx).is_empty()); } // ── QA probe (Marvin) — FR-8 AC-8.2 for the button-LESS hard block ────────── @@ -961,3 +972,92 @@ fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { text.borrow() ); } + +// ── Cross-finding reconciliations (lead brief) ────────────────────────────── + +/// Reconciliation #1 (SEC-004 / Diziet F-1) — while a secret prompt is active the +/// app gates `claim_input` OFF, so only `render_global` runs over the overlay. It +/// must NOT strip keyboard events, or it would eat the prompt's Enter/Esc/Tab. +/// (TC-OVL-048 separately proves the prompt renders interactively above the +/// overlay; this proves the overlay does not swallow its keyboard.) +#[test] +fn reconciliation_render_global_keeps_keyboard_for_prompt() { + let saw_keys = Rc::new(Cell::new(false)); + let saw_keys_ui = Rc::clone(&saw_keys); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + // No claim_input — mirrors AppState gating it off while a prompt is up. + ProgressOverlay::render_global(ui.ctx()); + let survived = ui.ctx().input(|i| { + i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { + key: egui::Key::Enter | egui::Key::Escape | egui::Key::Tab, + pressed: true, + .. + } + ) + }) + }); + saw_keys_ui.set(survived); + }); + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + assert!(!saw_keys.get(), "no keys injected yet"); + + // Inject the prompt's navigation/confirm keys; after render_global they must + // still be present (the overlay must not consume them). + for key in [egui::Key::Enter, egui::Key::Escape, egui::Key::Tab] { + harness.input_mut().events.push(egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + harness.step(); + assert!( + saw_keys.get(), + "render_global must not swallow Enter/Esc/Tab while an overlay is up — a \ + secret prompt above it needs them (SEC-004/F-1)" + ); +} + +/// Reconciliation #3 (QA-003) — the instance `Component::show` must NOT seize the +/// host screen's focus or install the global focus-lock. A host text field stays +/// focused after the inline overlay renders, proving the trap is global-only. +#[test] +fn reconciliation_instance_show_leaves_host_focus_navigable() { + let overlay = Rc::new(RefCell::new( + ProgressOverlay::new() + .with_description("Inline overlay.") + .with_button("inline.act", "Act"), + )); + let overlay_ui = Rc::clone(&overlay); + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + overlay_ui.borrow_mut().show(ui); + }); + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + + // The inline overlay rendered (trap_focus = false), so the host field keeps + // focus — an instance widget never wedges the host screen's navigation. + assert!( + harness + .get_by_role(egui::accesskit::Role::TextInput) + .is_focused(), + "the instance overlay must leave the host screen's focus alone (QA-003)" + ); +} From 7840703e4118d882901d450c55b1a600bbdd1203 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 20:40:06 +0200 Subject: [PATCH 09/22] docs(overlay): sync requirements/dev-plan to the shipped design + add UX user story MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 01-requirements-ux.md: add a supersession callout flagging the Cancel-era items now overtaken by the generic-button + watchdog + claim_input redesign (FR-7, AC-7.3/7.4, NFR-3 AC-3b, AC-8.4, AC-10.5, J-1/J-2/J-3, §6.3-6.5), pointing at the dev-plan post-outage note, the addendum, and the code as source of truth. - 03-dev-plan.md: drop OVERLAY_CANCEL_ACTION_ID from the §2 re-export row; mark the §3 API block superseded (real surface is with_button/with_secondary_button, keyed take_actions/sweep_orphan_actions, OptionOverlayExt::raise, the watchdog); fix the §4.1 drain comment; update the §9 D-4/D-5 rows. - user-stories.md: add UX-001 (blocking please-wait overlay; cannot fire a conflicting second action), tagged across personas, [Implemented]. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 22 ++++++++++++++++++ .../03-dev-plan.md | 23 ++++++++++++++----- docs/user-stories.md | 15 ++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index f20452fa2..b827d783d 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -8,6 +8,28 @@ --- +> **Supersession callout (post-outage redesign + QA-wave addendum).** Parts of this spec describe +> a first-class **Cancel** control that no longer exists. The shipped design replaces it with a +> **generic button facility** (`with_button` / `with_secondary_button`, clicks delivered keyed to +> the owning screen), and adds a no-progress **watchdog** and a frame-start **`claim_input`** total +> block. Where this document and the redesign disagree, **`03-dev-plan.md`'s post-outage note, +> `04-design-addendum.md`, and the code (`src/ui/components/progress_overlay.rs`) win.** Items known +> to be superseded: +> - **FR-7 (buttons & actions), AC-7.3 / AC-7.4** — no built-in Cancel; buttons are generic and +> styled Primary (right) / Secondary (left) in insertion order within each tier; clicks are +> delivered to the owning screen via `OverlayHandle::take_actions` (keyed), not a Cancel action. +> - **NFR-3 AC-3b (Esc → Cancel)** — Esc never cancels; a hard block swallows Esc/Tab/Enter/Space. +> While a secret prompt is shown above the overlay, the overlay yields the keyboard to it (SEC-004). +> - **AC-8.4 (backdrop / input)** — input is claimed at frame start (`claim_input`), including +> `Event::Text`; a button-less block is genuinely total (QA-001). +> - **AC-10.5 (concurrent actions)** — only the topmost entry's clicks are reachable, and they are +> keyed to that entry's owner; the app loop only sweeps orphaned ids. +> - **J-1 / J-2 / J-3 (journeys) and §6.3 / §6.4 / §6.5 (cancel UX)** — reframe any "Cancel button" +> language to the generic-button + escalation model; the safety valve is the bounded-operation +> contract + 30 s / 120 s honest escalation, not a dismiss/background control. + +--- + ## 0. Executive Summary Some operations in Dash Evo Tool are not safe to interrupt and are not meaningful to diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index d96efcade..eb97bd38d 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -177,7 +177,7 @@ top stays untestable until T7; note it as such. | `OptionOverlayExt` (handle lifecycle ext) | `progress_overlay.rs` | Mirrors `OptionBannerExt` (`message_banner.rs:915-955`). | | `render_global` call site + `drain_overlay_actions` | `src/app.rs` (`AppState::update`) | AppState-level render seam (§3). | | kittest suite | `tests/kittest/progress_overlay.rs` (**new**) + `mod progress_overlay;` in `tests/kittest/main.rs` | Mirrors `tests/kittest/message_banner.rs`. | -| `mod progress_overlay;` + re-exports | `src/ui/components/mod.rs` | Export `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `OVERLAY_CANCEL_ACTION_ID`, `OptionOverlayExt` (mirror banner re-exports). | +| `mod progress_overlay;` + re-exports | `src/ui/components/mod.rs` | Export `ProgressOverlay`, `OverlayHandle`, `OverlayConfig`, `ProgressOverlayResponse`, `OptionOverlayExt` (mirror banner re-exports). _(Superseded: no `OVERLAY_CANCEL_ACTION_ID` — removed in the post-outage redesign.)_ | **No new crates.** Everything reuses what is already a dependency: `egui::Spinner` (idiom already used at `src/ui/wallets/shielded_tab.rs:285` etc.), `ctx.data`, `DashColors`, @@ -188,8 +188,19 @@ top stays untestable until T7; note it as such. ## 3. Public API Design -Names are aligned **exactly** to Marvin's assumed surface (test-spec §1.2) so the 49 cases -compile unchanged. Signatures mirror `BannerHandle`/`MessageBanner`. +> **Superseded — see the code and the addendum.** The signature block below is the *original* +> Cancel-era plan, kept for history. The shipped surface differs: there is **no** `with_cancel`, +> `with_action`, `OVERLAY_CANCEL_ACTION_ID`, or `CANCEL_LABEL`, and `is_primary` is not a public +> field. The real builders are `with_button(id, label)` and `with_secondary_button(id, label)` +> (on `OverlayConfig`, `OverlayHandle`, and the instance form), backed by a private +> `ButtonStyle { Primary, Secondary }`. Clicks are delivered **keyed** to the owner via +> `OverlayHandle::take_actions()`; the static drain is `sweep_orphan_actions()` (see addendum §2). +> `OptionOverlayExt::raise` replaces the former `replace`. The watchdog +> (`STUCK_OVERLAY_WATCHDOG_THRESHOLD`, `claim_input`) is specified in the addendum §1. Treat +> `progress_overlay.rs` as the source of truth. + +Names were aligned to Marvin's assumed surface (test-spec §1.2). Signatures mirror +`BannerHandle`/`MessageBanner`. ```rust // src/ui/components/progress_overlay.rs @@ -331,7 +342,7 @@ self.render_secret_prompt(ctx); // unchanged — stays ABOVE overlay (app // … app.rs:1539-1540 … self.handle_banner_esc(ctx); self.drain_banner_actions(ctx); -self.drain_overlay_actions(ctx); // NEW — maps OVERLAY_CANCEL_ACTION_ID per D-5 +self.drain_overlay_actions(ctx); // sweeps ORPHAN actions only (addendum §2 A-3) ``` On network switch, clear the overlay alongside banners (mirror `MessageBanner::clear_all_global`). @@ -527,8 +538,8 @@ TODO so the gap is tracked, not lost. | D-1 | New `ProgressOverlay` component mirroring `MessageBanner`; do not extend the banner | Confirmed | | D-2 | Focused copy of `ctx.data` plumbing; no shared base module | Confirmed | | D-3 | Concurrent model = **stack** keyed by handle (Group K stands) | Confirmed | -| D-4 | Stuck threshold = **30 s**, informational reveal; escape-hatch deferred to T7 | Decided | -| D-5 | Backend has **no** cancellation → button-less block default; Cancel API ships unwired | Decided | +| D-4 | Stuck threshold = **30 s** soft reveal; **+120 s no-progress watchdog** (addendum §1 A-1) | Superseded by addendum §1 | +| D-5 | No built-in Cancel; generic `with_button`/`with_secondary_button`; clicks keyed to the owner via `take_actions`, app sweeps orphans (addendum §2) | Superseded (post-outage + addendum §2) | --- diff --git a/docs/user-stories.md b/docs/user-stories.md index 2514c36ef..f607042c5 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -17,6 +17,7 @@ See [docs/personas/](personas/) for full persona descriptions. - [Developer and Power Tools (DEV)](#developer-and-power-tools-dev) - [Network and Settings (NET)](#network-and-settings-net) - [Programmatic Access (MCP)](#programmatic-access-mcp) +- [User Experience (UX)](#user-experience-ux) --- @@ -1121,3 +1122,17 @@ As an AI agent, I want MCP server access so that I can assist users with wallet - Bearer token authentication for HTTP mode. - Network verification guard prevents cross-network mistakes. - Tools expose wallet, identity, and platform operations. + +--- + +## User Experience (UX) + +### UX-001: Blocking progress overlay for unsafe-to-interrupt operations [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, while a long operation that is unsafe to interrupt is running (broadcasting a state transition, signing, key import, a multi-step registration, a network migration), I want to see a clear please-wait block over the whole window so that I understand the app is busy and cannot accidentally fire a conflicting second action. + +- A full-window dimming overlay with an indeterminate spinner and an optional "Step N of M" counter and description appears while the operation runs, and lowers automatically when it finishes (success or error). +- All interaction beneath the block is suppressed: pointer clicks hit a sink, and keyboard/text input is claimed at frame start so nothing reaches a focused field beneath (FR-8 / QA-001). The block is never dismissable by Esc, Enter, Space, or Tab. +- The block yields to a passphrase prompt: when a secret prompt is shown above the overlay it keeps the keyboard (Enter/Esc/Tab) so the user can still authenticate or cancel (SEC-004). +- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer warning. There is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. From c51279e62c52ff81bb46047aa03953ccd3d0e2f9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:15:02 +0200 Subject: [PATCH 10/22] test(overlay): close re-QA coverage residuals RQ-1/RQ-2/RQ-3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RQ-1 (security) — the app.rs secret-prompt gate had no test; deleting `if self.active_secret_prompt.is_none()` left every test green. Extracted the gate into `AppState::claim_overlay_input` (called from `update`) and added a `#[cfg(feature = "testing")]` seam (`AppState::test_set_secret_prompt_active`, `ActivePrompt::test_stub`). New AppState-level kittest `rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay` drives the REAL `update()` loop with a prompt active over a button-less overlay and asserts the prompt input keeps focus AND accepts typed text (types a passphrase + Enter, the prompt submits and closes). Deleting the gate makes `claim_input` (button-less → `stop_text_input`) steal focus and strip the keys, failing both assertions. Extended `tc_ovl_048` to assert prompt interactivity (submit button renders + input holds focus), not just visibility. RQ-2 — added a `#[cfg(feature = "testing")]` clock seam `OverlayHandle::backdate` (shifts `created_at` + `last_progress_at` into the past). New kittest `tc_ovl_047b_threshold_reveals_via_clock_seam` renders past 30 s and 120 s and asserts: the soft "This is taking longer than usual." line + Elapsed force-reveal, then `STUCK_WATCHDOG_REASSURANCE` REPLACING the soft line (never both) — the addendum §1 obligation that was previously only flag-checked. RQ-3 — reframed the `tc_ovl_047` doc comment (the escape-hatch button is a deliberate v1 non-feature per addendum §1, not a deferred T7 TODO); added a "(superseded)" note to 01-requirements-ux.md's "what to reuse" list where it still cited `with_cancel`/`with_action`. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 12 +- src/app.rs | 32 +++- src/ui/components/progress_overlay.rs | 17 +++ src/ui/components/secret_prompt_host.rs | 19 +++ tests/kittest/progress_overlay.rs | 144 +++++++++++++++++- 5 files changed, 208 insertions(+), 16 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index b827d783d..fe67ff169 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -382,10 +382,14 @@ The overlay should **copy the proven *patterns***, keeping its own type: 1. **Global state in egui `ctx.data` temp storage**, keyed by a dedicated id (e.g. `__global_progress_overlay`) — same mechanism as `BANNER_STATE_ID`. 2. **A lifecycle `OverlayHandle`** mirroring `BannerHandle` (`set_description`, `set_step`, - `with_cancel`, `clear`), all returning `Option` and no-op on a dismissed overlay. -3. **An action-id queue** drained by the app loop, mirroring `with_action` / `take_action` - (`push_action`/`get_actions`/`set_actions`) — keeps the overlay UI-only; backend dispatch - stays in `AppState`. This is the i18n-clean, `ctx.data`-friendly equivalent of "callbacks": + `with_button` / `with_secondary_button`, `clear`), all returning `Option` and no-op on a + dismissed overlay. _(Superseded: no `with_cancel` — buttons are generic; see the dev-plan + post-outage note and the code.)_ +3. **An action-id queue** drained by the app loop. As shipped (addendum §2) the queue is **keyed** + per overlay entry: a click enqueues against the owner's key, the owner drains its own ids via + `OverlayHandle::take_actions`, and the app loop only `sweep_orphan_actions` — keeps the overlay + UI-only; backend dispatch stays in `AppState`. This is the i18n-clean, `ctx.data`-friendly + equivalent of "callbacks": storing closures in temp storage is awkward (not `Clone`); an opaque action id is the established seam. 4. **Log-once discipline** via a `logged` flag (NFR-5). diff --git a/src/app.rs b/src/app.rs index 9b3f1c69e..fe4a07b53 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1146,6 +1146,28 @@ impl AppState { /// their own colon-namespaced ids, including their own cancellation. This loop /// only reclaims truly-orphaned ids so they can't accumulate in `ctx.data`; it /// can never race or pre-empt a live owner, so its position here is safe. + /// Claim all keyboard + text input for an active blocking overlay at frame + /// start (QA-001) — UNLESS a secret prompt is active above it. The prompt + /// renders above the overlay and needs the keyboard (Enter to submit, Esc to + /// cancel, Tab to navigate; SEC-004/F-1), so the overlay must yield to it. + /// Extracted from `update` so the gate is exercised by a kittest (RQ-1): + /// removing the `active_secret_prompt.is_none()` guard must fail that test. + fn claim_overlay_input(&self, ctx: &egui::Context) { + if self.active_secret_prompt.is_none() { + ProgressOverlay::claim_input(ctx); + } + } + + /// Test seam (RQ-1): force a secret prompt to be active (or not) so a kittest + /// can drive the REAL `update()` loop — including the + /// [`claim_overlay_input`](Self::claim_overlay_input) gate and + /// `render_secret_prompt` — and assert that the prompt above an overlay stays + /// focusable/typeable. Compiled only under the `testing` feature. + #[cfg(feature = "testing")] + pub fn test_set_secret_prompt_active(&mut self, active: bool) { + self.active_secret_prompt = active.then(ActivePrompt::test_stub); + } + fn drain_overlay_actions(&mut self, ctx: &egui::Context) { for action_id in ProgressOverlay::sweep_orphan_actions(ctx) { tracing::warn!( @@ -1534,13 +1556,9 @@ impl App for AppState { } // Total input block at frame start: while a blocking overlay is up, claim - // all keyboard + text input BEFORE the panels run, so a button-less block - // cannot leak typed characters into a focused field beneath (QA-001). - // Gated on no active secret prompt — that modal renders above the overlay - // and must keep the keyboard (e.g. Esc to cancel a passphrase entry). - if self.active_secret_prompt.is_none() { - ProgressOverlay::claim_input(ctx); - } + // all keyboard + text input BEFORE the panels run (QA-001) — unless a + // secret prompt is active above the overlay (it needs the keyboard). + self.claim_overlay_input(ctx); // Show welcome screen if onboarding not completed let mut actions = Vec::new(); diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index 51b5926ec..21493dcd8 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -335,6 +335,23 @@ impl OverlayHandle { mine } + /// Test clock seam (RQ-2): shift this entry's `created_at` **and** + /// `last_progress_at` into the past by `by`, so a kittest can render past the + /// 30 s soft-reveal and 120 s no-progress watchdog thresholds without waiting. + /// Returns `None` if the entry is gone. Compiled only under the `testing` + /// feature — never part of the production surface. + #[cfg(feature = "testing")] + pub fn backdate(&self, by: Duration) -> Option<&Self> { + self.mutate(|s| { + if let Some(t) = s.created_at.checked_sub(by) { + s.created_at = t; + } + if let Some(t) = s.last_progress_at.checked_sub(by) { + s.last_progress_at = t; + } + }) + } + /// Dismiss only this handle's entry, and purge any of its still-pending action /// ids so a normal dismiss leaves nothing for the orphan-sweeper (A-3). The /// overlay lowers when the stack empties. diff --git a/src/ui/components/secret_prompt_host.rs b/src/ui/components/secret_prompt_host.rs index b04116ee1..ae45767b1 100644 --- a/src/ui/components/secret_prompt_host.rs +++ b/src/ui/components/secret_prompt_host.rs @@ -100,6 +100,25 @@ impl ActivePrompt { } } + /// Build a stub active prompt for tests (RQ-1): no real secret, a reply + /// channel whose receiver is dropped immediately. Lets a test put + /// `AppState::active_secret_prompt` into the `Some` state to exercise the + /// overlay input-claim gate. Compiled only under the `testing` feature. + #[cfg(feature = "testing")] + pub fn test_stub() -> Self { + let (reply, _rx) = oneshot::channel(); + Self { + request: SecretPromptRequest::new( + crate::wallet_backend::secret_prompt::SecretScope::SingleKey { + address: "test".to_string(), + }, + "Test prompt", + ), + reply: Some(reply), + remember: false, + } + } + /// Render the modal for this frame. Returns `true` when the prompt has /// resolved (the caller should drop it and drain the next request). pub fn show(&mut self, ctx: &egui::Context) -> bool { diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 204b44833..dedba920e 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -831,6 +831,7 @@ fn tc_ovl_048_secret_prompt_renders_above_overlay() { }); let _handle = ProgressOverlay::show_global(&harness.ctx, "Signing.", OverlayConfig::default()); harness.step(); + harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); assert!( @@ -839,13 +840,28 @@ fn tc_ovl_048_secret_prompt_renders_above_overlay() { .is_some(), "the secret prompt renders above the overlay and remains visible" ); + // RQ-1: the prompt is INTERACTIVE above the overlay, not merely visible — its + // submit control renders and its input holds keyboard focus, so the overlay's + // Foreground dim/sink does not capture the prompt's interaction. + assert!( + harness.query_by_label("Unlock").is_some(), + "the prompt's submit button renders interactively above the overlay" + ); + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "the prompt input holds keyboard focus above the overlay" + ); } -/// TC-OVL-047 (informational portion) — the stuck-threshold reveal is exercised -/// by the inline unit test `stuck_reveal_triggers_only_past_threshold` in -/// `src/ui/components/progress_overlay.rs`; the elapsed readout and reassurance -/// line are forced on once `created_at` passes 30s. The escape-hatch button is -/// deferred with backend cancellation (T7). +/// TC-OVL-047 (informational portion) — below the threshold a default overlay +/// shows no elapsed readout and no reassurance line. The past-threshold reveals +/// (soft 30 s line, the 120 s watchdog line replacing it, and the Elapsed +/// force-reveal) are exercised by `tc_ovl_047b_threshold_reveals_via_clock_seam` +/// using the test clock seam; the threshold predicates by the inline +/// `stuck_reveal_*` / `watchdog_tripped_*` unit tests. Per addendum §1 there is NO +/// escape-hatch button by design — a button-less block stays total and the safety +/// valve is the bounded-op contract + honest escalation — so that portion of +/// TC-OVL-047 is closed as "won't build" for v1, not deferred to T7. #[test] fn tc_ovl_047_stuck_threshold_is_informational_only() { // Below the threshold a default overlay shows no elapsed readout and no @@ -861,6 +877,64 @@ fn tc_ovl_047_stuck_threshold_is_informational_only() { ); } +/// TC-OVL-047b (RQ-2) — using the test clock seam, render PAST the thresholds and +/// assert the addendum's escalation: the soft 30 s line + Elapsed force-reveal, +/// then the 120 s watchdog line REPLACING the soft line (never stacked). +#[cfg(feature = "testing")] +#[test] +fn tc_ovl_047b_threshold_reveals_via_clock_seam() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label_contains("Elapsed:").is_none()); + assert!( + harness + .query_by_label("This is taking longer than usual.") + .is_none() + ); + + // Past 30 s (still making progress recently): soft line + Elapsed force-reveal, + // no watchdog yet. + handle.backdate(Duration::from_secs(31)); + harness.step(); + assert!( + harness.query_by_label_contains("Elapsed:").is_some(), + "Elapsed is force-revealed once past 30 s, even though with_elapsed was off" + ); + assert!( + harness + .query_by_label("This is taking longer than usual.") + .is_some(), + "the soft reassurance line appears past 30 s" + ); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "the watchdog line has not tripped yet" + ); + + // Past 120 s with no progress: the watchdog line REPLACES the soft line. + handle.backdate(Duration::from_secs(120)); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_some(), + "the 120 s no-progress watchdog line appears" + ); + assert!( + harness + .query_by_label("This is taking longer than usual.") + .is_none(), + "the watchdog line replaces the soft line, never stacks with it" + ); + assert!( + harness.query_by_label_contains("Elapsed:").is_some(), + "the Elapsed readout persists through the watchdog escalation" + ); +} + /// TC-OVL-045 (ctx.data portion) — `OptionOverlayExt::raise` swaps the entry /// and `take_and_clear` lowers it; the log-once flag itself is asserted in the /// inline unit tests. @@ -1061,3 +1135,63 @@ fn reconciliation_instance_show_leaves_host_focus_navigable() { "the instance overlay must leave the host screen's focus alone (QA-003)" ); } + +// ── RQ-1: AppState-level secret-prompt gate ───────────────────────────────── + +/// RQ-1 (security) — drives the REAL `AppState::update` loop: a passphrase prompt +/// active above a button-less blocking overlay stays focusable AND typeable, +/// because `AppState::claim_overlay_input` suppresses the overlay's frame-start +/// `claim_input` while a secret prompt is active (SEC-004/F-1). Deleting that gate +/// makes `claim_input` (button-less → `stop_text_input`) steal the prompt's focus +/// and strip its keystrokes — which BOTH assertions below detect. +#[cfg(feature = "testing")] +#[test] +fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // A secret prompt is active (renders above the overlay, needs keyboard). + app.test_set_secret_prompt_active(true); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Raise a button-less blocking overlay beneath the active prompt. + ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.run_steps(5); + + // The prompt renders above the overlay... + assert!( + harness.query_by_label_contains("Test prompt").is_some(), + "the secret prompt renders above the overlay" + ); + // ...and KEEPS keyboard focus: deleting the gate lets the overlay's + // claim_input (stop_text_input, button-less) clear it → focused() == None. + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "the prompt input keeps keyboard focus over the overlay — removing the \ + app.rs secret-prompt gate lets the overlay's claim_input steal it" + ); + + // ...and ACCEPTS typed text: type a passphrase and submit with Enter; the + // prompt resolves and closes. With the gate deleted the keystrokes are + // stripped, so the prompt would stay open and this would fail. + harness + .input_mut() + .events + .push(egui::Event::Text("pw".to_string())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + assert!( + harness.query_by_label_contains("Test prompt").is_none(), + "the prompt accepted the typed passphrase and submitted on Enter (it \ + would stay open if the overlay had stripped its keyboard)" + ); + }); +} From 66d8ec0db084be29c41e9b34a9673dbf4cb8ae3a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 21:30:46 +0200 Subject: [PATCH 11/22] =?UTF-8?q?docs(overlay):=20close=20QA=20residuals?= =?UTF-8?q?=20=E2=80=94=20README=20catalog=20entry,=20requirements=20Cance?= =?UTF-8?q?l=20reconciliation,=20T7=20TODO?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-gate cleanup on the blocking progress overlay (gate green): - README: add a ProgressOverlay row to the Feedback Components table, covering show_global/render_global, with_button(id, label), the 120s watchdog, and companions OverlayConfig/OverlayHandle/OptionOverlayExt/ ProgressOverlayResponse. - 01-requirements-ux.md: reconcile the remaining literal-Cancel acceptance criteria (intro line, AC-7.3, AC-8.4, the §6.5 "Visible, cancelable" row, R-3) to the shipped generic-button model, matching the top supersession callout — Esc/Tab/Enter/Space are swallowed and there is no built-in Cancel. - app.rs: mark drain_overlay_actions with a TODO(T7) recording that an overlay button can only stop waiting (not abort) until the BackendTask system gains cooperative cancellation; until then the 120s watchdog (see progress_overlay.rs) bounds every block. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 13 ++++++++----- src/app.rs | 13 +++++++++++++ src/ui/components/README.md | 1 + 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index fe67ff169..655d44dbd 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -43,7 +43,8 @@ This spec defines a **full-screen blocking progress overlay**: a sibling capabil beneath it, and shows a "please wait" message with an **indeterminate spinner (no ETA)**, an **optional step counter** (`Step {current} of {total}`), and **optional action buttons** (at minimum Cancel). It is dismissed programmatically when the operation completes, or by an -optional Cancel button. +optional Cancel button. _(Superseded — see top banner: buttons are generic (`with_button`), there +is no built-in Cancel, and Esc/Tab/Enter/Space are swallowed; dismissal is programmatic.)_ **Critical invariant (learned the hard way — PR860):** the overlay is a *visual + input* block only. It must never synchronously wait in the egui frame loop. The real work runs on a @@ -128,7 +129,7 @@ The overlay MAY show zero or more action buttons. Cancel is the canonical one bu mechanism is generic. **AC-7.1** Buttons are optional. With none, the overlay is a pure block dismissed only programmatically. **AC-7.2** Each button carries a label (i18n unit) and an opaque **action id**. Clicking pushes the action id into an overlay-action queue that the app loop drains and dispatches — exactly mirroring `BannerHandle::with_action` / `MessageBanner::take_action`. The overlay never calls backend code directly (UI-only seam; see NFR-1 and §6). -**AC-7.3** A Cancel button uses a well-known action id; the app loop maps it to the operation's cancellation path. +**AC-7.3** A Cancel button uses a well-known action id; the app loop maps it to the operation's cancellation path. _(Superseded — see top banner: buttons are generic with opaque ids keyed to the owning screen; there is no well-known Cancel id and no app-loop cancellation mapping.)_ **AC-7.4** Buttons follow project button order (Confirm/primary RIGHT, Cancel LEFT) and use `StyledButton`/`ComponentStyles`, never bare `ui.button()`. **AC-7.5** Cancel SHOULD be offered only when the operation is genuinely cancelable (see Risk R-3). When it cannot truly cancel, do not show a button that lies. @@ -139,7 +140,7 @@ overlay's own controls. **AC-8.1** Pointer clicks/drags on any region outside the overlay's own buttons have no effect on the UI beneath. **AC-8.2** Keyboard input (Tab, Enter, typing) does not reach widgets beneath the overlay. (Do not rely on `Ui::set_enabled()` — deprecated in egui 0.33; use a top input-capturing layer instead.) **AC-8.3** The block covers the *entire* window, including top and left panels — therefore the overlay renders at the `AppState` level, not inside `island_central_panel()` (which only wraps central content). See §3. -**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. +**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. _(Superseded — see top banner: there is no built-in Cancel; dismissal is programmatic. A screen MAY add a generic button (`with_button`) that triggers its own teardown.)_ ### FR-9 — Coexistence with MessageBanner (z-order + hand-off) **AC-9.1** The overlay renders **above** all `MessageBanner` banners (banners live inside the island content area at a background layer; the overlay sits on a top layer). The overlay wins z-order. @@ -348,7 +349,7 @@ The card uses the dialog idiom (rounded corners, shadow, `surface`/`window_fill` | State | Behavior | |---|---| | Visible, no buttons | Pure block. Esc/Enter swallowed. Backdrop click ignored. Dismissed only programmatically. | -| Visible, cancelable | Esc → Cancel. Cancel button focusable and is the first focus stop. Backdrop click ignored. | +| Visible, cancelable | _(Superseded — see top banner: Esc never cancels (it is swallowed with Tab/Enter/Space); buttons are generic, not a focusable Cancel. Backdrop click ignored.)_ | | Button hover/focus | Standard `StyledButton` hover (pointing-hand) + focus ring (`BORDER_WIDTH_THICK`, ≥3:1). | | Counter update | Only the counter line changes; spinner uninterrupted. | | Theme switch mid-overlay | Colors re-evaluate next frame; no stale palette. | @@ -418,7 +419,9 @@ focused copy over a forced shared base; defer to Nagatha. - **R-2 — Concurrent blocking operations (FR-10).** Confirm the stack model vs. last-writer-replace vs. reject-second. Stack is safest (never unblocks while a task runs); simpler models need a product guarantee that blockers don't overlap. -- **R-3 — Does Cancel actually cancel?** The honesty of the Cancel button depends on the +- **R-3 — Does Cancel actually cancel?** _(Superseded — see top banner: no built-in Cancel ships; + cooperative backend cancellation (T7) is deferred and the 120s watchdog bounds every block + instead. The note below records the original concern.)_ The honesty of the Cancel button depends on the `BackendTask` system supporting cooperative cancellation (cancel tokens / abortable tasks). If a task cannot truly be aborted, Cancel can only *stop waiting* while the work continues — which is misleading and unsafe (e.g. a broadcast). **Verify backend cancellation support diff --git a/src/app.rs b/src/app.rs index fe4a07b53..010d09029 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1168,6 +1168,19 @@ impl AppState { self.active_secret_prompt = active.then(ActivePrompt::test_stub); } + /// Sweep orphaned overlay action ids whose owning overlay is gone. Screens own + /// dispatch and cancellation today — they drain their own clicks via + /// [`OverlayHandle::take_actions`]; this loop only reclaims orphans so they + /// cannot accumulate in `ctx.data`. + // + // TODO(T7): the BackendTask system has no cooperative cancellation, so an + // overlay button can only stop waiting, never abort a running operation. When + // T7 lands (thread a per-operation CancellationToken through run_backend_task + // and retain the abort handle in handle_backend_task), a screen can wire a + // generic overlay button — e.g. one it labels "Cancel" — to a real abort. + // Until then no production overlay attaches a button to a running task, and + // this loop has no live cancellation role; the 120s watchdog + // (see progress_overlay.rs) bounds every block in the meantime. fn drain_overlay_actions(&mut self, ctx: &egui::Context) { for action_id in ProgressOverlay::sweep_orphan_actions(ctx) { tracing::warn!( diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 6fe85d7e9..9c0422498 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -34,6 +34,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | Description | |-----------|------|-------------| | `MessageBanner` | `message_banner.rs` | Global error/warning/success/info banners. `set_global()`, `with_details()`, auto-dismiss. Extensions: `OptionBannerExt`, `OptionBannerShowExt`, `ResultBannerExt` | +| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_button(id, label)`), 120s watchdog. Global path: `show_global()` / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt`, `ProgressOverlayResponse` | ## Styled Components (`styled.rs`) From 22ff0cb965c3a209f1055b023f5ed03e4469f893 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:16:24 +0200 Subject: [PATCH 12/22] feat(overlay): hard-block the UI during startup/Connect SPV sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raises the blocking ProgressOverlay while a startup- or Connect-initiated SPV sync runs, and lowers it when the chain becomes usable (Synced) or fails (Error). Honors the overlay's C1/C2 caller contract. SPV sync is UNBOUNDED — it can wait indefinitely for peers — so a button-less block would trap the user. The block therefore carries a "Continue in the background" escape (`SYNC_CONTINUE_BACKGROUND_ACTION`); clicking it lowers the block while sync proceeds safely in the background (read-only — nothing is stranded). C1: the block also always lowers on its own at a terminal state. - `AppState`: `sync_overlay`/`sync_block_active`/`sync_overlay_dismissed` fields; armed on boot auto-start and on the manual `StartSpv` (Connect); reset on network switch so the handle never goes stale. - New per-frame `update_sync_overlay` driver (called beside `update_connection_banner`) applies a pure, unit-tested policy `sync_block_step` (Block / Release / Idle) and drains the escape click. - Pure decision + descriptions are i18n-clean single sentences. Tests: 6 inline unit tests of `sync_block_step` (inactive→Idle; active+not-usable →Block; terminal→Release for both dismissed states; dismissed→Idle; stable action id; sentence descriptions). New `#[cfg(feature = "testing")]` integration kittest `task9_sync_overlay_blocks_lowers_on_synced_and_on_escape` drives the real `update_sync_overlay` against a forced connection state: asserts the block raises while connecting, lowers on Synced (C1), and lowers on the escape click (C2 — user never trapped). Adds `ConnectionStatus::set_overall_state` + AppState `test_activate_sync_block`/`test_drive_sync_overlay` test seams. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 246 +++++++++++++++++++++++++++++- src/context/connection_status.rs | 9 ++ tests/kittest/progress_overlay.rs | 73 +++++++++ 3 files changed, 326 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 010d09029..3e039fd14 100644 --- a/src/app.rs +++ b/src/app.rs @@ -15,7 +15,10 @@ use crate::logging::initialize_logger; use crate::model::feature_gate::FeatureGate; use crate::model::settings::AppSettings; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; -use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ProgressOverlay}; +use crate::ui::components::{ + BannerHandle, MessageBanner, OptionBannerExt, OptionOverlayExt, OverlayConfig, OverlayHandle, + ProgressOverlay, +}; use crate::ui::contracts_documents::contracts_documents_screen::DocumentQueryScreen; use crate::ui::dashpay::{DashPayScreen, DashPaySubscreen, ProfileSearchScreen}; use crate::ui::dpns::dpns_contested_names_screen::{ @@ -58,6 +61,63 @@ use tokio::sync::mpsc as tokiompsc; /// risking a typo collision. Exposed for kittest coverage. pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; +/// Action id for the SPV-sync block's "Continue in the background" escape button. +/// SPV sync is unbounded (it can wait indefinitely for peers), so a button-less +/// hard block would trap the user (violating the overlay's C1/C2 contract). This +/// escape lowers the block while sync continues safely in the background — a +/// read-only operation that strands nothing if backgrounded. Colon-namespaced per +/// the overlay action-id convention. Exposed for kittest coverage. +pub const SYNC_CONTINUE_BACKGROUND_ACTION: &str = "sync:overlay:continue_background"; + +/// What the per-frame sync-block driver should do with the overlay this frame, +/// given whether a startup/Connect sync is being blocked, whether the user chose +/// to continue in the background, and the current connection state. Pure so the +/// policy is unit-testable in isolation from `AppState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SyncBlockStep { + /// Raise (or keep) the hard block. + Block, + /// Sync reached a terminal state (Synced or Error): lower the block and end + /// this blocking episode (C1 — clear on every terminal path). + Release, + /// Not blocking — inactive, or the user chose to continue in the background: + /// ensure no block is shown without ending the episode bookkeeping. + Idle, +} + +/// Pure sync-block policy (C1/C2): block only while a startup/Connect sync is +/// active and not user-dismissed and the chain is not yet usable; release on a +/// terminal state so the block always lowers through the normal path. +fn sync_block_step(active: bool, dismissed: bool, state: OverallConnectionState) -> SyncBlockStep { + use OverallConnectionState as S; + if !active { + return SyncBlockStep::Idle; + } + match state { + // Usable, or failed (the connection banner surfaces the error): release. + S::Synced | S::Error => SyncBlockStep::Release, + // Still working: block, unless the user has chosen to wait in background. + S::Disconnected | S::Connecting | S::Syncing => { + if dismissed { + SyncBlockStep::Idle + } else { + SyncBlockStep::Block + } + } + } +} + +/// Calm, actionable description for the SPV-sync block (the escape button is the +/// action). One complete sentence per i18n rules. +fn sync_block_description(state: OverallConnectionState) -> &'static str { + match state { + OverallConnectionState::Syncing => { + "Syncing with the Dash network. This can take a little while." + } + _ => "Connecting to the Dash network.", + } +} + /// One-sentence user-facing label for an in-progress migration step. /// Mirrors Diziet §2.2 D-1 banner copy — single complete sentence per /// variant so i18n extraction is trivial. Exposed for kittest @@ -177,6 +237,19 @@ pub struct AppState { previous_connection_state: Option, /// Handle to the current connection status banner, if one is displayed connection_banner_handle: Option, + /// The blocking progress overlay raised during a startup / Connect SPV sync, + /// hard-blocking the UI until the chain reaches a usable (Synced) or failed + /// (Error) state. See [`Self::update_sync_overlay`]. + sync_overlay: Option, + /// Whether a startup- or Connect-initiated SPV sync is currently being + /// hard-blocked. Set on boot auto-start and on the manual Connect; cleared + /// when the sync reaches a terminal state. + sync_block_active: bool, + /// Set when the user clicks the overlay's "Continue in the background" escape + /// (C1/C2 — SPV sync is unbounded, so the block must never trap the user). The + /// block stays down for the rest of this sync episode; sync continues in the + /// background. Reset when a new sync episode begins. + sync_overlay_dismissed: bool, /// Handle to the current data-migration banner, if one is displayed. /// Kept so per-frame reconciliation can update text in place /// (Detecting → SingleKey → Shielded → WalletSeeds → WalletMeta → Finalize → Success/Failed) @@ -669,6 +742,10 @@ impl AppState { welcome_screen: None, previous_connection_state: None, connection_banner_handle: None, + sync_overlay: None, + // Hard-block the UI for the boot SPV sync when it auto-starts. + sync_block_active: boot_auto_start_spv, + sync_overlay_dismissed: false, migration_banner_handle: None, last_migration_state: None, cold_start_migration_dispatched: BTreeSet::new(), @@ -889,8 +966,12 @@ impl AppState { // network context — accepted risk for a local desktop app (cosmetic only). MessageBanner::clear_all_global(app_context.egui_ctx()); // Drop any blocking overlay from the previous context so the new network - // is never left behind a stale block. + // is never left behind a stale block. Also drop the sync-block bookkeeping + // so its handle never goes stale against the cleared `ctx.data`. ProgressOverlay::clear_all_global(app_context.egui_ctx()); + self.sync_overlay = None; + self.sync_block_active = false; + self.sync_overlay_dismissed = false; for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) @@ -1027,6 +1108,59 @@ impl AppState { self.handle_backend_task(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); } + /// Drive the blocking SPV-sync overlay each frame (Task 9): hard-block the UI + /// while a startup- or Connect-initiated sync runs, and lower it when the + /// chain becomes usable (Synced) or fails (Error). + /// + /// C1/C2 contract: SPV sync is **unbounded** — it can wait indefinitely for + /// peers — so a button-less block would trap the user. The block therefore + /// carries a "Continue in the background" escape ([`SYNC_CONTINUE_BACKGROUND_ACTION`]); + /// clicking it lowers the block while sync proceeds safely in the background + /// (read-only — nothing is stranded). The block also always lowers on its own + /// at a terminal state, so it can never strand the user behind a stale block. + fn update_sync_overlay(&mut self, ctx: &egui::Context, app_context: &Arc) { + let state = app_context.connection_status().overall_state(); + match sync_block_step(self.sync_block_active, self.sync_overlay_dismissed, state) { + SyncBlockStep::Block => { + let description = sync_block_description(state); + if self.sync_overlay.is_none() { + let config = OverlayConfig::new() + .with_description(description) + .with_button( + SYNC_CONTINUE_BACKGROUND_ACTION, + "Continue in the background", + ); + self.sync_overlay.raise(ctx, "", config); + } else if let Some(handle) = &self.sync_overlay { + handle.set_description(description); + } + } + SyncBlockStep::Release => { + self.sync_overlay.take_and_clear(); + self.sync_block_active = false; + self.sync_overlay_dismissed = false; + } + SyncBlockStep::Idle => { + self.sync_overlay.take_and_clear(); + } + } + + // Drain this overlay's own clicks: the "Continue in the background" escape + // lowers the block for the rest of this episode (sync keeps running). + let actions = self + .sync_overlay + .as_ref() + .map(|handle| handle.take_actions()) + .unwrap_or_default(); + if actions + .iter() + .any(|id| id == SYNC_CONTINUE_BACKGROUND_ACTION) + { + self.sync_overlay_dismissed = true; + self.sync_overlay.take_and_clear(); + } + } + /// Update the migration banner to reflect the current /// [`MigrationState`]. Each step / outcome surfaces a single /// i18n-ready sentence per Diziet §2.2 D-1. On `Failed`, the @@ -1168,6 +1302,24 @@ impl AppState { self.active_secret_prompt = active.then(ActivePrompt::test_stub); } + /// Test seam (Task 9): arm a startup/Connect SPV-sync block episode. + #[cfg(feature = "testing")] + pub fn test_activate_sync_block(&mut self) { + self.sync_block_active = true; + self.sync_overlay_dismissed = false; + } + + /// Test seam (Task 9): run the REAL `update_sync_overlay` driver once against + /// the active context's (forced) connection state, in isolation from the + /// throttled frame loop. Lets a kittest assert the block raises while + /// connecting, lowers when usable, and lowers on the "continue in the + /// background" escape. + #[cfg(feature = "testing")] + pub fn test_drive_sync_overlay(&mut self, ctx: &egui::Context) { + let app_context = self.current_app_context().clone(); + self.update_sync_overlay(ctx, &app_context); + } + /// Sweep orphaned overlay action ids whose owning overlay is gone. Screens own /// dispatch and cancellation today — they drain their own clicks via /// [`OverlayHandle::take_actions`]; this loop only reclaims orphans so they @@ -1600,6 +1752,7 @@ impl App for AppState { ); self.update_connection_banner(ctx, &active_context); + self.update_sync_overlay(ctx, &active_context); self.dispatch_cold_start_migration(); self.update_migration_banner(ctx, &active_context); self.handle_banner_esc(ctx); @@ -1657,6 +1810,10 @@ impl App for AppState { self.set_main_screen(root_screen_type); } AppAction::StartSpv => { + // Hard-block the UI for this manual Connect until the chain is + // usable or fails (a fresh episode — re-arm the escape). + self.sync_block_active = true; + self.sync_overlay_dismissed = false; let app_ctx = self.current_app_context().clone(); let sender = self.task_result_sender.clone(); let egui_ctx = ctx.clone(); @@ -1787,3 +1944,88 @@ mod migration_banner_tests { assert_eq!(MIGRATION_RETRY_ACTION_ID, "migration:retry:finish_unwire"); } } + +#[cfg(test)] +mod sync_overlay_tests { + use super::*; + + const NOT_USABLE: [OverallConnectionState; 3] = [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ]; + + /// No active episode → never block, regardless of state or dismissal. + #[test] + fn inactive_is_always_idle() { + for state in [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + OverallConnectionState::Synced, + OverallConnectionState::Error, + ] { + assert_eq!(sync_block_step(false, false, state), SyncBlockStep::Idle); + assert_eq!(sync_block_step(false, true, state), SyncBlockStep::Idle); + } + } + + /// Active + not-yet-usable + not dismissed → hard block. + #[test] + fn active_blocks_while_not_usable() { + for state in NOT_USABLE { + assert_eq!(sync_block_step(true, false, state), SyncBlockStep::Block); + } + } + + /// C1 — a terminal state (usable or failed) always releases the block, even + /// if the user had dismissed it: the episode is over. + #[test] + fn terminal_state_always_releases() { + for dismissed in [false, true] { + assert_eq!( + sync_block_step(true, dismissed, OverallConnectionState::Synced), + SyncBlockStep::Release + ); + assert_eq!( + sync_block_step(true, dismissed, OverallConnectionState::Error), + SyncBlockStep::Release + ); + } + } + + /// C2 escape — "continue in the background" stops blocking but does NOT end the + /// episode: sync keeps running, the user is just no longer trapped. + #[test] + fn dismissed_stops_blocking_without_releasing() { + for state in NOT_USABLE { + assert_eq!(sync_block_step(true, true, state), SyncBlockStep::Idle); + } + } + + /// The escape action id is stable — production raises it and + /// `update_sync_overlay` matches on it; a typo would drop the click. + #[test] + fn continue_background_action_id_is_stable() { + assert_eq!( + SYNC_CONTINUE_BACKGROUND_ACTION, + "sync:overlay:continue_background" + ); + } + + /// Descriptions are complete sentences (i18n) and Syncing has distinct copy. + #[test] + fn descriptions_are_sentences_and_state_specific() { + for state in NOT_USABLE { + assert!( + sync_block_description(state).ends_with('.'), + "`{}` is not a complete sentence", + sync_block_description(state) + ); + } + assert_ne!( + sync_block_description(OverallConnectionState::Syncing), + sync_block_description(OverallConnectionState::Connecting), + ); + } +} diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 5d3924ee2..fee4ccb97 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -412,6 +412,15 @@ impl ConnectionStatus { self.overall_state.load(Ordering::Relaxed).into() } + /// Test seam: force the overall connection state directly. Bypasses + /// [`Self::refresh_state`] so a test that drives a consumer in isolation (not + /// the throttled frame loop) sees a stable state. Compiled only under the + /// `testing` feature. + #[cfg(feature = "testing")] + pub fn set_overall_state(&self, state: OverallConnectionState) { + self.overall_state.store(state as u8, Ordering::Relaxed); + } + /// Recompute the overall connection state from the individual subsystem /// flags. /// diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index dedba920e..4748ecbc3 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -1195,3 +1195,76 @@ fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { ); }); } + +// ── Task 9: SPV-sync blocking overlay (startup + Connect) ──────────────────── + +/// Task 9 — the SPV-sync block raises while connecting, lowers automatically once +/// the chain is usable (C1), and lowers on the "Continue in the background" +/// escape so an unbounded sync never traps the user (C2). Drives the REAL +/// `AppState::update_sync_overlay` against a forced connection state. +#[cfg(feature = "testing")] +#[test] +fn task9_sync_overlay_blocks_lowers_on_synced_and_on_escape() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx()); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + // Separate Arc clone so we can force connection state without borrowing app. + let app_context = app.current_app_context().clone(); + + // Arm a Connect-style block and force "connecting": the block raises. + app.test_activate_sync_block(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_drive_sync_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "the block is raised while connecting" + ); + + // C1 — usable: the block lowers on its own. + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Synced); + app.test_drive_sync_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block lowers automatically once synced" + ); + + // C2 — re-arm, connect, and dismiss via the escape: the block lowers even + // though sync is still (unbounded) in progress, so the user is never trapped. + app.test_activate_sync_block(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_drive_sync_overlay(&harness.ctx); + // Settle the centered card before clicking the escape button. + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "the escape button renders on the sync block" + ); + harness.get_by_label("Continue in the background").click(); + harness.step(); + app.test_drive_sync_overlay(&harness.ctx); + harness.step(); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the 'continue in the background' escape lowers the block (user never trapped)" + ); + }); +} From f8851dc6b8655aa3cefcb6ea5298c05da9db33fa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:29:41 +0200 Subject: [PATCH 13/22] refactor(overlay): align SPV-sync block to the approved spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reworks the SPV-sync overlay wiring (introduced in the previous commit) to the user-approved design. Net behaviour: while the active context is Connecting or Syncing the overlay hard-blocks the UI, lowering when the chain becomes usable (Synced), fails (Error), or drops (Disconnected). Changes vs the first cut: - Keyed purely to the live connection state + a per-episode dismissal flag — drops the separate "armed" flag, so any sync episode (startup, Connect, or reconnect) blocks. Pure policy renamed `sync_block_step` -> `spv_block_step` (Block/Release/Stand); Disconnected now Releases + re-arms. - Escape is now an always-visible SECONDARY button "Continue in the background" (id renamed `spv:sync:continue_background`); fields renamed to `spv_overlay`/`spv_overlay_dismissed`; method renamed `update_spv_overlay` and driven BEFORE `update_connection_banner`. - Live content: description = `spv_phase_summary(progress)` (else a generic connecting line), plus a "Step N of 5" counter via new `connection_status::spv_phase_step` (Headers=1 … Blocks=5). Raises once per episode, then updates in place. - Suppresses the redundant Connecting/Syncing connection-banner text while the overlay is up (don't double-shout); keeps Error/Disconnected banners. C1/C2 contract preserved: SPV sync is UNBOUNDED, so the escape (lower while sync continues safely in the background — read-only, nothing stranded) guarantees the user is never trapped; episode-ending states always release. Tests updated: 4 inline `spv_block_step` unit tests; the integration kittest `task9_spv_overlay_blocks_lowers_on_synced_and_on_escape` now also asserts the secondary escape button, re-raise for a fresh episode, no re-raise within a dismissed episode, and re-raise after the episode ends. Test seams renamed to `AppState::test_drive_spv_overlay` (+ `ConnectionStatus::set_overall_state`). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 328 +++++++++++++++--------------- src/context/connection_status.rs | 24 +++ tests/kittest/progress_overlay.rs | 80 +++++--- 3 files changed, 232 insertions(+), 200 deletions(-) diff --git a/src/app.rs b/src/app.rs index 3e039fd14..5a6af66f4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,9 @@ use crate::backend_task::error::TaskError; use crate::backend_task::migration::MigrationTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::context::connection_status::{ConnectionStatus, OverallConnectionState}; +use crate::context::connection_status::{ + ConnectionStatus, OverallConnectionState, spv_phase_step, spv_phase_summary, +}; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; #[cfg(not(feature = "testing"))] @@ -62,62 +64,54 @@ use tokio::sync::mpsc as tokiompsc; pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; /// Action id for the SPV-sync block's "Continue in the background" escape button. -/// SPV sync is unbounded (it can wait indefinitely for peers), so a button-less -/// hard block would trap the user (violating the overlay's C1/C2 contract). This -/// escape lowers the block while sync continues safely in the background — a -/// read-only operation that strands nothing if backgrounded. Colon-namespaced per -/// the overlay action-id convention. Exposed for kittest coverage. -pub const SYNC_CONTINUE_BACKGROUND_ACTION: &str = "sync:overlay:continue_background"; - -/// What the per-frame sync-block driver should do with the overlay this frame, -/// given whether a startup/Connect sync is being blocked, whether the user chose -/// to continue in the background, and the current connection state. Pure so the -/// policy is unit-testable in isolation from `AppState`. +/// SPV sync is **unbounded** — with no peers it stays Connecting/Syncing forever +/// with no terminal signal — so a button-less hard block would trap the user +/// (violating the overlay's C1/C2 contract). This escape lowers the block while +/// sync continues safely in the background — a read-only operation that strands +/// nothing if backgrounded. Colon-namespaced per the overlay action-id +/// convention. Exposed for kittest coverage. +pub const SPV_CONTINUE_BACKGROUND_ACTION: &str = "spv:sync:continue_background"; + +/// Generic description shown while connecting with no per-phase progress yet. +const SPV_CONNECTING_DESCRIPTION: &str = "Connecting to the Dash network."; + +/// What the per-frame SPV-sync block driver should do with the overlay this +/// frame, given whether the user chose to continue in the background and the +/// current connection state. Pure so the policy is unit-testable in isolation +/// from `AppState`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SyncBlockStep { - /// Raise (or keep) the hard block. +enum SpvBlockStep { + /// Connecting/Syncing and not dismissed: raise (or keep + update) the block. Block, - /// Sync reached a terminal state (Synced or Error): lower the block and end - /// this blocking episode (C1 — clear on every terminal path). + /// Episode ended (Synced, Error, or Disconnected): lower the block and reset + /// the per-episode dismissal so a fresh sync blocks again (C1). Release, - /// Not blocking — inactive, or the user chose to continue in the background: - /// ensure no block is shown without ending the episode bookkeeping. - Idle, + /// Still connecting/syncing but the user chose to continue in the background: + /// keep the block lowered without ending the episode (C2 escape). + Stand, } -/// Pure sync-block policy (C1/C2): block only while a startup/Connect sync is -/// active and not user-dismissed and the chain is not yet usable; release on a -/// terminal state so the block always lowers through the normal path. -fn sync_block_step(active: bool, dismissed: bool, state: OverallConnectionState) -> SyncBlockStep { +/// Pure SPV-sync block policy (C1/C2). The block is keyed only to the live +/// connection state and the per-episode dismissal flag — there is no separate +/// "armed" flag, so any Connecting/Syncing episode (startup, Connect, or a +/// reconnect) blocks until usable, failed, disconnected, or dismissed. +fn spv_block_step(dismissed: bool, state: OverallConnectionState) -> SpvBlockStep { use OverallConnectionState as S; - if !active { - return SyncBlockStep::Idle; - } match state { - // Usable, or failed (the connection banner surfaces the error): release. - S::Synced | S::Error => SyncBlockStep::Release, - // Still working: block, unless the user has chosen to wait in background. - S::Disconnected | S::Connecting | S::Syncing => { + // Usable, failed (banner surfaces the error), or dropped: the episode is + // over — lower and re-arm for the next sync. + S::Synced | S::Error | S::Disconnected => SpvBlockStep::Release, + // Still working: block unless the user is waiting in the background. + S::Connecting | S::Syncing => { if dismissed { - SyncBlockStep::Idle + SpvBlockStep::Stand } else { - SyncBlockStep::Block + SpvBlockStep::Block } } } } -/// Calm, actionable description for the SPV-sync block (the escape button is the -/// action). One complete sentence per i18n rules. -fn sync_block_description(state: OverallConnectionState) -> &'static str { - match state { - OverallConnectionState::Syncing => { - "Syncing with the Dash network. This can take a little while." - } - _ => "Connecting to the Dash network.", - } -} - /// One-sentence user-facing label for an in-progress migration step. /// Mirrors Diziet §2.2 D-1 banner copy — single complete sentence per /// variant so i18n extraction is trivial. Exposed for kittest @@ -237,19 +231,18 @@ pub struct AppState { previous_connection_state: Option, /// Handle to the current connection status banner, if one is displayed connection_banner_handle: Option, - /// The blocking progress overlay raised during a startup / Connect SPV sync, - /// hard-blocking the UI until the chain reaches a usable (Synced) or failed - /// (Error) state. See [`Self::update_sync_overlay`]. - sync_overlay: Option, - /// Whether a startup- or Connect-initiated SPV sync is currently being - /// hard-blocked. Set on boot auto-start and on the manual Connect; cleared - /// when the sync reaches a terminal state. - sync_block_active: bool, + /// The blocking progress overlay raised while SPV chain sync is in progress + /// (Connecting/Syncing), hard-blocking the UI until the chain becomes usable + /// (Synced) / fails (Error) / drops (Disconnected), or the user dismisses it. + /// The overlay's first real adopter (PR #863 wiring). See + /// [`Self::update_spv_overlay`]. + spv_overlay: Option, /// Set when the user clicks the overlay's "Continue in the background" escape /// (C1/C2 — SPV sync is unbounded, so the block must never trap the user). The - /// block stays down for the rest of this sync episode; sync continues in the - /// background. Reset when a new sync episode begins. - sync_overlay_dismissed: bool, + /// block stays down for the rest of *this* sync episode; sync continues in the + /// background. Reset when the episode ends (Synced/Error/Disconnected) so a + /// fresh sync blocks again. + spv_overlay_dismissed: bool, /// Handle to the current data-migration banner, if one is displayed. /// Kept so per-frame reconciliation can update text in place /// (Detecting → SingleKey → Shielded → WalletSeeds → WalletMeta → Finalize → Success/Failed) @@ -742,10 +735,8 @@ impl AppState { welcome_screen: None, previous_connection_state: None, connection_banner_handle: None, - sync_overlay: None, - // Hard-block the UI for the boot SPV sync when it auto-starts. - sync_block_active: boot_auto_start_spv, - sync_overlay_dismissed: false, + spv_overlay: None, + spv_overlay_dismissed: false, migration_banner_handle: None, last_migration_state: None, cold_start_migration_dispatched: BTreeSet::new(), @@ -966,12 +957,11 @@ impl AppState { // network context — accepted risk for a local desktop app (cosmetic only). MessageBanner::clear_all_global(app_context.egui_ctx()); // Drop any blocking overlay from the previous context so the new network - // is never left behind a stale block. Also drop the sync-block bookkeeping - // so its handle never goes stale against the cleared `ctx.data`. + // is never left behind a stale block. Also drop the SPV-sync overlay + // bookkeeping so its handle never goes stale against the cleared `ctx.data`. ProgressOverlay::clear_all_global(app_context.egui_ctx()); - self.sync_overlay = None; - self.sync_block_active = false; - self.sync_overlay_dismissed = false; + self.spv_overlay = None; + self.spv_overlay_dismissed = false; for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) @@ -1019,6 +1009,23 @@ impl AppState { return; } + // While the SPV-sync block (`update_spv_overlay`) is up it already conveys + // the Connecting/Syncing state with live phase progress, so suppress the + // redundant connection-banner text — don't double-shout. The Error / + // Disconnected banners still show, since the overlay has lowered by then. + if self.spv_overlay.is_some() + && matches!( + current_state, + OverallConnectionState::Connecting | OverallConnectionState::Syncing + ) + { + if let Some(handle) = self.connection_banner_handle.take() { + handle.clear(); + } + self.previous_connection_state = Some(current_state); + return; + } + // Clear old banner on state transitions if state_changed && let Some(handle) = self.connection_banner_handle.take() { handle.clear(); @@ -1108,56 +1115,80 @@ impl AppState { self.handle_backend_task(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); } - /// Drive the blocking SPV-sync overlay each frame (Task 9): hard-block the UI - /// while a startup- or Connect-initiated sync runs, and lower it when the - /// chain becomes usable (Synced) or fails (Error). + /// Drive the blocking SPV-sync overlay each frame (Task 9 — the overlay's + /// first real adopter, PR #863 wiring). Hard-block the UI while the active + /// context's chain sync is Connecting/Syncing, showing the live per-phase + /// summary and a "Step N of 5" counter, and lower it when the chain becomes + /// usable (Synced), fails (Error), or drops (Disconnected). /// - /// C1/C2 contract: SPV sync is **unbounded** — it can wait indefinitely for - /// peers — so a button-less block would trap the user. The block therefore - /// carries a "Continue in the background" escape ([`SYNC_CONTINUE_BACKGROUND_ACTION`]); - /// clicking it lowers the block while sync proceeds safely in the background - /// (read-only — nothing is stranded). The block also always lowers on its own - /// at a terminal state, so it can never strand the user behind a stale block. - fn update_sync_overlay(&mut self, ctx: &egui::Context, app_context: &Arc) { - let state = app_context.connection_status().overall_state(); - match sync_block_step(self.sync_block_active, self.sync_overlay_dismissed, state) { - SyncBlockStep::Block => { - let description = sync_block_description(state); - if self.sync_overlay.is_none() { - let config = OverlayConfig::new() - .with_description(description) - .with_button( - SYNC_CONTINUE_BACKGROUND_ACTION, + /// C1/C2 contract: SPV sync is **unbounded** — with no peers it stays + /// Connecting/Syncing forever with no terminal signal — so a button-less block + /// would trap the user. The block therefore carries a "Continue in the + /// background" escape ([`SPV_CONTINUE_BACKGROUND_ACTION`]); clicking it lowers + /// the block while sync proceeds safely in the background (read-only — nothing + /// is stranded). It also always lowers on its own when the episode ends. + /// + /// Raises the overlay at most once per episode (then updates content in place + /// via the handle), so it never `show_global`s every frame. + fn update_spv_overlay(&mut self, ctx: &egui::Context, app_context: &Arc) { + let cs = app_context.connection_status(); + let state = cs.overall_state(); + match spv_block_step(self.spv_overlay_dismissed, state) { + SpvBlockStep::Block => { + let progress = cs.spv_sync_progress(); + let description = progress + .as_ref() + .map(spv_phase_summary) + .unwrap_or_else(|| SPV_CONNECTING_DESCRIPTION.to_string()); + let step = progress.as_ref().and_then(spv_phase_step); + if self.spv_overlay.is_none() { + let mut config = OverlayConfig::new() + .with_description(&description) + .with_secondary_button( + SPV_CONTINUE_BACKGROUND_ACTION, "Continue in the background", ); - self.sync_overlay.raise(ctx, "", config); - } else if let Some(handle) = &self.sync_overlay { - handle.set_description(description); + if let Some(n) = step { + config = config.with_step(n, 5); + } + self.spv_overlay.raise(ctx, "", config); + } else if let Some(handle) = &self.spv_overlay { + handle.set_description(&description); + match step { + Some(n) => { + handle.set_step(n, 5); + } + None => { + handle.clear_step(); + } + } } } - SyncBlockStep::Release => { - self.sync_overlay.take_and_clear(); - self.sync_block_active = false; - self.sync_overlay_dismissed = false; + SpvBlockStep::Release => { + // Episode over: lower and re-arm so a fresh sync blocks again (C1). + self.spv_overlay.take_and_clear(); + self.spv_overlay_dismissed = false; } - SyncBlockStep::Idle => { - self.sync_overlay.take_and_clear(); + SpvBlockStep::Stand => { + // User chose to continue in the background: stay lowered, but keep + // the dismissal so we don't re-raise within this episode (C2). + self.spv_overlay.take_and_clear(); } } // Drain this overlay's own clicks: the "Continue in the background" escape // lowers the block for the rest of this episode (sync keeps running). let actions = self - .sync_overlay + .spv_overlay .as_ref() .map(|handle| handle.take_actions()) .unwrap_or_default(); if actions .iter() - .any(|id| id == SYNC_CONTINUE_BACKGROUND_ACTION) + .any(|id| id == SPV_CONTINUE_BACKGROUND_ACTION) { - self.sync_overlay_dismissed = true; - self.sync_overlay.take_and_clear(); + self.spv_overlay_dismissed = true; + self.spv_overlay.take_and_clear(); } } @@ -1302,22 +1333,15 @@ impl AppState { self.active_secret_prompt = active.then(ActivePrompt::test_stub); } - /// Test seam (Task 9): arm a startup/Connect SPV-sync block episode. - #[cfg(feature = "testing")] - pub fn test_activate_sync_block(&mut self) { - self.sync_block_active = true; - self.sync_overlay_dismissed = false; - } - - /// Test seam (Task 9): run the REAL `update_sync_overlay` driver once against + /// Test seam (Task 9): run the REAL `update_spv_overlay` driver once against /// the active context's (forced) connection state, in isolation from the /// throttled frame loop. Lets a kittest assert the block raises while - /// connecting, lowers when usable, and lowers on the "continue in the - /// background" escape. + /// connecting, lowers when usable/failed/disconnected, and lowers on the + /// "continue in the background" escape. #[cfg(feature = "testing")] - pub fn test_drive_sync_overlay(&mut self, ctx: &egui::Context) { + pub fn test_drive_spv_overlay(&mut self, ctx: &egui::Context) { let app_context = self.current_app_context().clone(); - self.update_sync_overlay(ctx, &app_context); + self.update_spv_overlay(ctx, &app_context); } /// Sweep orphaned overlay action ids whose owning overlay is gone. Screens own @@ -1751,8 +1775,10 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); + // Drive the SPV-sync block first so the connection banner can suppress its + // redundant Connecting/Syncing text while the overlay is up. + self.update_spv_overlay(ctx, &active_context); self.update_connection_banner(ctx, &active_context); - self.update_sync_overlay(ctx, &active_context); self.dispatch_cold_start_migration(); self.update_migration_banner(ctx, &active_context); self.handle_banner_esc(ctx); @@ -1810,10 +1836,6 @@ impl App for AppState { self.set_main_screen(root_screen_type); } AppAction::StartSpv => { - // Hard-block the UI for this manual Connect until the chain is - // usable or fails (a fresh episode — re-arm the escape). - self.sync_block_active = true; - self.sync_overlay_dismissed = false; let app_ctx = self.current_app_context().clone(); let sender = self.task_result_sender.clone(); let egui_ctx = ctx.clone(); @@ -1946,86 +1968,54 @@ mod migration_banner_tests { } #[cfg(test)] -mod sync_overlay_tests { +mod spv_overlay_tests { use super::*; - const NOT_USABLE: [OverallConnectionState; 3] = [ - OverallConnectionState::Disconnected, - OverallConnectionState::Connecting, - OverallConnectionState::Syncing, - ]; - - /// No active episode → never block, regardless of state or dismissal. + /// Connecting/Syncing and not dismissed → hard block. #[test] - fn inactive_is_always_idle() { + fn connecting_or_syncing_blocks_when_not_dismissed() { for state in [ - OverallConnectionState::Disconnected, OverallConnectionState::Connecting, OverallConnectionState::Syncing, - OverallConnectionState::Synced, - OverallConnectionState::Error, ] { - assert_eq!(sync_block_step(false, false, state), SyncBlockStep::Idle); - assert_eq!(sync_block_step(false, true, state), SyncBlockStep::Idle); + assert_eq!(spv_block_step(false, state), SpvBlockStep::Block); } } - /// Active + not-yet-usable + not dismissed → hard block. + /// C2 escape — dismissed during Connecting/Syncing → Stand (no block), but the + /// episode is NOT ended (sync keeps running; the user is just not trapped). #[test] - fn active_blocks_while_not_usable() { - for state in NOT_USABLE { - assert_eq!(sync_block_step(true, false, state), SyncBlockStep::Block); + fn dismissed_stands_down_without_ending_episode() { + for state in [ + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ] { + assert_eq!(spv_block_step(true, state), SpvBlockStep::Stand); } } - /// C1 — a terminal state (usable or failed) always releases the block, even - /// if the user had dismissed it: the episode is over. + /// C1 — episode-ending states (usable, failed, disconnected) always release + /// and re-arm, regardless of whether the user had dismissed the block. #[test] - fn terminal_state_always_releases() { + fn terminal_states_always_release() { for dismissed in [false, true] { - assert_eq!( - sync_block_step(true, dismissed, OverallConnectionState::Synced), - SyncBlockStep::Release - ); - assert_eq!( - sync_block_step(true, dismissed, OverallConnectionState::Error), - SyncBlockStep::Release - ); - } - } - - /// C2 escape — "continue in the background" stops blocking but does NOT end the - /// episode: sync keeps running, the user is just no longer trapped. - #[test] - fn dismissed_stops_blocking_without_releasing() { - for state in NOT_USABLE { - assert_eq!(sync_block_step(true, true, state), SyncBlockStep::Idle); + for state in [ + OverallConnectionState::Synced, + OverallConnectionState::Error, + OverallConnectionState::Disconnected, + ] { + assert_eq!(spv_block_step(dismissed, state), SpvBlockStep::Release); + } } } /// The escape action id is stable — production raises it and - /// `update_sync_overlay` matches on it; a typo would drop the click. + /// `update_spv_overlay` matches on it; a typo would drop the click. #[test] fn continue_background_action_id_is_stable() { assert_eq!( - SYNC_CONTINUE_BACKGROUND_ACTION, - "sync:overlay:continue_background" - ); - } - - /// Descriptions are complete sentences (i18n) and Syncing has distinct copy. - #[test] - fn descriptions_are_sentences_and_state_specific() { - for state in NOT_USABLE { - assert!( - sync_block_description(state).ends_with('.'), - "`{}` is not a complete sentence", - sync_block_description(state) - ); - } - assert_ne!( - sync_block_description(OverallConnectionState::Syncing), - sync_block_description(OverallConnectionState::Connecting), + SPV_CONTINUE_BACKGROUND_ACTION, + "spv:sync:continue_background" ); } } diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index fee4ccb97..ca38d115e 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -640,6 +640,30 @@ pub fn spv_phase_summary(progress: &SpvSyncProgress) -> String { "syncing...".to_string() } +/// Map the currently-active SPV sync phase to a 1-based step number for the +/// blocking overlay's "Step N of 5" counter — Headers=1, Masternodes=2, +/// Filter Headers=3, Filters=4, Blocks=5 — or `None` when no phase is actively +/// syncing yet. Mirrors the pipeline order of [`spv_phase_summary`]. +pub fn spv_phase_step(progress: &SpvSyncProgress) -> Option { + let is_syncing = |state: SyncState| state == SyncState::Syncing; + if progress.headers().is_ok_and(|p| is_syncing(p.state())) { + Some(1) + } else if progress.masternodes().is_ok_and(|p| is_syncing(p.state())) { + Some(2) + } else if progress + .filter_headers() + .is_ok_and(|p| is_syncing(p.state())) + { + Some(3) + } else if progress.filters().is_ok_and(|p| is_syncing(p.state())) { + Some(4) + } else if progress.blocks().is_ok_and(|p| is_syncing(p.state())) { + Some(5) + } else { + None + } +} + fn pct(current: u32, target: u32) -> u32 { if target == 0 { 0 diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 4748ecbc3..93c6fa24d 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -1198,13 +1198,14 @@ fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { // ── Task 9: SPV-sync blocking overlay (startup + Connect) ──────────────────── -/// Task 9 — the SPV-sync block raises while connecting, lowers automatically once -/// the chain is usable (C1), and lowers on the "Continue in the background" -/// escape so an unbounded sync never traps the user (C2). Drives the REAL -/// `AppState::update_sync_overlay` against a forced connection state. +/// Task 9 — the SPV-sync block raises while Connecting/Syncing, lowers +/// automatically when the chain becomes usable (C1), lowers on the "Continue in +/// the background" escape without re-raising in the same episode (C2), and +/// re-raises for a fresh sync episode. Drives the REAL `AppState::update_spv_overlay` +/// against a forced connection state. #[cfg(feature = "testing")] #[test] -fn task9_sync_overlay_blocks_lowers_on_synced_and_on_escape() { +fn task9_spv_overlay_blocks_lowers_on_synced_and_on_escape() { use dash_evo_tool::context::connection_status::OverallConnectionState; crate::support::with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); @@ -1219,52 +1220,69 @@ fn task9_sync_overlay_blocks_lowers_on_synced_and_on_escape() { .expect("Failed to create AppState"); // Separate Arc clone so we can force connection state without borrowing app. let app_context = app.current_app_context().clone(); + let set_state = |s| app_context.connection_status().set_overall_state(s); - // Arm a Connect-style block and force "connecting": the block raises. - app.test_activate_sync_block(); - app_context - .connection_status() - .set_overall_state(OverallConnectionState::Connecting); - app.test_drive_sync_overlay(&harness.ctx); + // Connecting → the block raises (no separate "armed" flag; it keys off state). + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); assert!( ProgressOverlay::has_global(&harness.ctx), - "the block is raised while connecting" + "the block raises while connecting" + ); + // The escape button is a SECONDARY button labelled "Continue in the background". + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "the escape button renders on the sync block" ); // C1 — usable: the block lowers on its own. - app_context - .connection_status() - .set_overall_state(OverallConnectionState::Synced); - app.test_drive_sync_overlay(&harness.ctx); + set_state(OverallConnectionState::Synced); + app.test_drive_spv_overlay(&harness.ctx); assert!( !ProgressOverlay::has_global(&harness.ctx), "the block lowers automatically once synced" ); - // C2 — re-arm, connect, and dismiss via the escape: the block lowers even - // though sync is still (unbounded) in progress, so the user is never trapped. - app.test_activate_sync_block(); - app_context - .connection_status() - .set_overall_state(OverallConnectionState::Connecting); - app.test_drive_sync_overlay(&harness.ctx); - // Settle the centered card before clicking the escape button. + // A fresh episode (back to Connecting) re-raises the block. + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); harness.step(); harness.step(); harness.step(); assert!( - harness - .query_by_label("Continue in the background") - .is_some(), - "the escape button renders on the sync block" + ProgressOverlay::has_global(&harness.ctx), + "a fresh sync episode re-raises the block" ); + + // C2 — click the escape: the block lowers even though sync is still + // (unbounded) in progress, so the user is never trapped... harness.get_by_label("Continue in the background").click(); harness.step(); - app.test_drive_sync_overlay(&harness.ctx); - harness.step(); + app.test_drive_spv_overlay(&harness.ctx); assert!( !ProgressOverlay::has_global(&harness.ctx), - "the 'continue in the background' escape lowers the block (user never trapped)" + "the escape lowers the block (user never trapped)" + ); + // ...and it stays down for the rest of THIS episode (still Connecting). + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block is not re-raised within the dismissed episode" + ); + + // A new episode (Disconnected resets the dismissal, then Connecting) re-raises. + set_state(OverallConnectionState::Disconnected); + app.test_drive_spv_overlay(&harness.ctx); + set_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "after the episode ends and a new sync begins, the block re-raises" ); }); } From cc896ea545540c183c2e62ed4b8aa4a1d3485654 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:34:51 +0200 Subject: [PATCH 14/22] docs(overlay): reconcile SPV-sync block decision (F-SPV-1) + phase-step test (F-SPV-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-SPV-1 — the user-authorized SPV-sync hard-block + always-visible "Continue in the background" escape contradicted three docs written for the standalone overlay. Reconcile the docs to the decision (the feature is correct; the docs were stale) so a future dev does not "correctly" remove the button per old docs: - docs/user-stories.md: carve out the SPV-sync exception in UX-001's "no background/dismiss button" guarantee, and add UX-002 — the blocking SPV-sync overlay with the always-on "Continue in the background" escape (tagged across personas, [Implemented]). - 01-requirements-ux.md §5: supersession note — the user chose to block the startup/Connect get-connected sync; the power-user concern is mitigated by the escape (sync is read-only and safe to background); this is the overlay's first adopter. - 04-design-addendum.md A-1: record that A-1's "ship NO dismiss/background button in v1" was scoped to unsafe-to-interrupt ops whose safety rests on boundedness; for the unbounded-but-read-only SPV-sync adopter the C2 "never trap the user" guarantee is met by the always-on escape, which must NOT be removed. F-SPV-2 — the granular phase progress (spv_phase_summary description + "Step N of 5" via spv_phase_step) was already wired in the previous commit; adds a unit test locking the active-phase → step mapping and the summary text. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 10 ++++++++++ .../04-design-addendum.md | 12 ++++++++++++ docs/user-stories.md | 12 +++++++++++- src/context/connection_status.rs | 15 +++++++++++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index 655d44dbd..9abcfd00f 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -244,6 +244,16 @@ trap). > ignore (e.g. ambient SPV sync, identity discovery sweeps) stays a non-blocking > `MessageBanner::with_elapsed()` progress banner. Blocking the whole UI for ambient sync > would punish Priya and Jordan, who legitimately work while syncing. +> +> **Superseded for the startup/Connect SPV-sync adopter (user decision).** The user has since +> chosen to **block** the UI during the initial get-connected SPV sync (startup auto-start and the +> Connect button — not ambient mid-session reconnect cosmetics), because letting the user act before +> the chain is usable is confusing. The power-user concern above is mitigated by an **always-visible +> "Continue in the background" escape**: SPV sync is read-only and safe to background, so Priya/Jordan +> can dismiss the block and keep working while sync proceeds. This is the overlay's first real adopter +> — see UX-002 in `docs/user-stories.md` and `AppState::update_spv_overlay` (`src/app.rs`). The +> escape is what satisfies the overlay's C2 "never trap the user" constraint, since SPV sync is +> unbounded (no terminal signal with no peers). ### J-1 — Identity registration (multi-step, counter + Cancel) — Priya / Jordan 1. User confirms "Register identity." diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md index a99b03807..f82c3a4e2 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -34,6 +34,18 @@ lowers through the normal path, and make a stuck block impossible by constructio ## 1. Decision 1 — Stuck/hang safety-valve (SEC-003, Diziet F-5) +> **Post-decision update (SPV-sync adopter, F-SPV-1).** The "ship NO dismiss/background button in +> v1" call below was scoped to the **unsafe-to-interrupt** operations (broadcast, signing, migration) +> whose safety rests on C1 + C2 *boundedness*. The user has since decided to make the **startup/Connect +> SPV sync** the overlay's first adopter (Task 9 / PR #863) and to **ship an always-visible +> "Continue in the background" escape** for it. That does not contradict this decision: SPV sync is +> **read-only and safe to background** (clicking the escape strands nothing — unlike a broadcast/ +> migration), and it is **unbounded** (no peers ⇒ no terminal signal), so its C2 "never trap the +> user" guarantee is met by the **always-on escape**, not by boundedness. A future dev must NOT +> "restore the original docs" by removing that button — it is the load-bearing safety valve for this +> adopter. See UX-002 (`docs/user-stories.md`), `01-requirements-ux.md` §5, and +> `AppState::update_spv_overlay`. + ### Decision **Keep the block total. Ship NO renderer-level dismiss/background button in v1.** The safety valve diff --git a/docs/user-stories.md b/docs/user-stories.md index f607042c5..fa74e73ef 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1135,4 +1135,14 @@ As a user, while a long operation that is unsafe to interrupt is running (broadc - A full-window dimming overlay with an indeterminate spinner and an optional "Step N of M" counter and description appears while the operation runs, and lowers automatically when it finishes (success or error). - All interaction beneath the block is suppressed: pointer clicks hit a sink, and keyboard/text input is claimed at frame start so nothing reaches a focused field beneath (FR-8 / QA-001). The block is never dismissable by Esc, Enter, Space, or Tab. - The block yields to a passphrase prompt: when a secret prompt is shown above the overlay it keeps the keyboard (Enter/Esc/Tab) so the user can still authenticate or cancel (SEC-004). -- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer warning. There is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. +- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer warning. For these unsafe-to-interrupt operations there is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. _(Exception: the startup/Connect SPV-sync block of UX-002 is unbounded but read-only, so it ships an always-visible "Continue in the background" escape instead.)_ + +### UX-002: Blocking SPV-sync overlay with a "continue in the background" escape [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, while the app connects to and syncs the Dash chain on startup or after I press Connect, I want a clear please-wait block so I know it is working — and because that sync can wait indefinitely for peers, I want an always-visible "Continue in the background" button so I am never trapped behind it. + +- While the active network is Connecting or Syncing, a full-window block appears showing the live sync phase (e.g. "Headers: 12345 / 27000 (45%)") and a "Step N of 5" counter (Headers → Masternodes → Filter Headers → Filters → Blocks). +- The block always offers a secondary "Continue in the background" button. Clicking it lowers the block; sync keeps running in the background (it is read-only and strands nothing), and the block is not re-raised for the rest of that sync episode. +- The block lowers on its own when the chain becomes usable (Synced), fails (Error), or drops (Disconnected); a fresh sync episode blocks again. +- This is the overlay's first real adopter (PR #863). Unlike the unsafe-to-interrupt operations in UX-001, SPV sync is **unbounded but safe to background** — so its C2 "never trap the user" guarantee is met by the always-on escape, not by operation boundedness. diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index ca38d115e..622aa33b5 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -710,6 +710,21 @@ mod tests { assert!(status.spv_sync_progress().is_none()); } + /// F-SPV-2 — the blocking overlay's "Step N of 5" counter maps to the active + /// phase, and the summary reflects the live headers phase. + #[test] + fn spv_phase_step_and_summary_track_active_phase() { + let progress = syncing_progress(); + assert_eq!(spv_phase_step(&progress), Some(1), "headers phase → step 1"); + assert!( + spv_phase_summary(&progress).starts_with("Headers: 5000 / 10000"), + "summary reflects the live headers phase" + ); + + // No phase actively syncing → no step (the overlay shows the generic line). + assert_eq!(spv_phase_step(&SpvSyncProgress::default()), None); + } + #[test] fn spv_status_snapshot_reflects_live_state() { let status = ConnectionStatus::new(); From 01364a9ac5e39b133ace0c7836f2fa80f959cae4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:50:10 +0200 Subject: [PATCH 15/22] fix(overlay): scope SPV block to user-initiated sync + de-jargon copy (F-SPV-A/B/E) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F-SPV-A (sev-2/1 regression, introduced by the prior refactor) — the SPV block fired on ANY Connecting/Syncing, so an ambient mid-session reconnect, or the SPV engine flipping Synced→Syncing as it processes each new block (event_bridge on_progress maps !is_synced() → Syncing), would hard-block a working user. Re-introduce a startup/Connect-SCOPED arming gate: - `spv_block_armed` flag, armed only on boot auto-start and the Connect button (AppAction::StartSpv); reset on network switch. - `spv_block_step(armed, dismissed, state)`: !armed → Idle (never block); armed + Synced/Error → Disarm (lower + clear armed); armed + Connecting/Syncing/ Disconnected → Block (or Stand if dismissed). Once disarmed, ambient sync never re-blocks until the next user-initiated episode. F-SPV-B (sev-2) — the block description showed blockchain jargon ("Headers: 12345 / 27000 (45%)") to the Everyday User. Replace with plain complete sentences ("Connecting to the Dash network." / "Syncing with the Dash network."); keep the jargon-free "Step N of 5" counter (via spv_phase_step) as the determinate granularity. spv_phase_summary stays (still used by wallets_screen); it is just no longer the overlay description. UX-002 acceptance criterion updated to stop enshrining the jargon. F-SPV-E (sev-4) — AppAction::StartSpv set an orphaned Info banner whose handle was dropped (could not be cleared by the overlay's banner suppression). Dropped it; the block conveys "connecting" and the error path still surfaces via replace_global. Tests: spv_block_step unit tests rewritten around the arming gate — `unarmed_never_blocks` is the regression guard (ambient sync never blocks); `armed_terminal_state_disarms`; jargon-free-description test. The integration kittest is rewritten to `task9_spv_overlay_armed_scope_disarm_and_escape`: an un-armed Connecting does NOT block, an armed one does, Synced disarms, ambient sync afterward does NOT re-block, the escape lowers without re-raising, and only a fresh armed episode re-blocks. New `AppState::test_arm_spv_block` seam. is_synced() finding: `EventBridge::on_progress` (event_bridge.rs) does map `!is_synced()` → `SpvStatus::Syncing`, so overall_state CAN flip Synced→Syncing on per-block catch-up — the arming gate makes that harmless (disarmed after the initial episode). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/user-stories.md | 4 +- src/app.rs | 214 +++++++++++++++++++++--------- tests/kittest/progress_overlay.rs | 70 ++++++---- 3 files changed, 199 insertions(+), 89 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index fa74e73ef..2af1eda77 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1142,7 +1142,7 @@ As a user, while a long operation that is unsafe to interrupt is running (broadc As a user, while the app connects to and syncs the Dash chain on startup or after I press Connect, I want a clear please-wait block so I know it is working — and because that sync can wait indefinitely for peers, I want an always-visible "Continue in the background" button so I am never trapped behind it. -- While the active network is Connecting or Syncing, a full-window block appears showing the live sync phase (e.g. "Headers: 12345 / 27000 (45%)") and a "Step N of 5" counter (Headers → Masternodes → Filter Headers → Filters → Blocks). +- While that startup/Connect sync is getting connected, a full-window block appears with a plain please-wait sentence ("Connecting to the Dash network." / "Syncing with the Dash network.") and a friendly progress indicator ("Step N of 5") — no blockchain jargon, raw heights, or percentages. - The block always offers a secondary "Continue in the background" button. Clicking it lowers the block; sync keeps running in the background (it is read-only and strands nothing), and the block is not re-raised for the rest of that sync episode. -- The block lowers on its own when the chain becomes usable (Synced), fails (Error), or drops (Disconnected); a fresh sync episode blocks again. +- The block is scoped to *user-initiated* sync (startup auto-start / Connect): it lowers on its own when the chain becomes usable (Synced) or fails (Error), and an **ambient** reconnect or per-block catch-up afterward does not block a working user. Pressing Connect (or a fresh startup) blocks again. - This is the overlay's first real adopter (PR #863). Unlike the unsafe-to-interrupt operations in UX-001, SPV sync is **unbounded but safe to background** — so its C2 "never trap the user" guarantee is met by the always-on escape, not by operation boundedness. diff --git a/src/app.rs b/src/app.rs index 5a6af66f4..899820190 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,9 +7,7 @@ use crate::backend_task::error::TaskError; use crate::backend_task::migration::MigrationTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::context::connection_status::{ - ConnectionStatus, OverallConnectionState, spv_phase_step, spv_phase_summary, -}; +use crate::context::connection_status::{ConnectionStatus, OverallConnectionState, spv_phase_step}; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; #[cfg(not(feature = "testing"))] @@ -72,37 +70,48 @@ pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; /// convention. Exposed for kittest coverage. pub const SPV_CONTINUE_BACKGROUND_ACTION: &str = "spv:sync:continue_background"; -/// Generic description shown while connecting with no per-phase progress yet. +/// Plain, jargon-free descriptions for the SPV-sync block (Everyday-User rule: +/// no "SPV"/"headers"/"masternodes"/raw heights/percentages — the jargon-free +/// "Step N of 5" counter carries the granularity). Complete sentences (NFR-2). const SPV_CONNECTING_DESCRIPTION: &str = "Connecting to the Dash network."; +const SPV_SYNCING_DESCRIPTION: &str = "Syncing with the Dash network."; /// What the per-frame SPV-sync block driver should do with the overlay this -/// frame, given whether the user chose to continue in the background and the -/// current connection state. Pure so the policy is unit-testable in isolation -/// from `AppState`. +/// frame, given whether a startup/Connect sync is **armed**, whether the user +/// chose to continue in the background, and the current connection state. Pure so +/// the policy is unit-testable in isolation from `AppState`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SpvBlockStep { - /// Connecting/Syncing and not dismissed: raise (or keep + update) the block. + /// Armed, not dismissed, still connecting/syncing: raise (or keep + update). Block, - /// Episode ended (Synced, Error, or Disconnected): lower the block and reset - /// the per-episode dismissal so a fresh sync blocks again (C1). - Release, - /// Still connecting/syncing but the user chose to continue in the background: - /// keep the block lowered without ending the episode (C2 escape). + /// Armed episode reached a terminal state (Synced/Error): lower the block and + /// DISARM, so subsequent ambient Connecting/Syncing (reconnect, per-block + /// catch-up) never re-blocks (F-SPV-A). + Disarm, + /// Armed but the user chose to continue in the background: keep the block + /// lowered without ending the episode (C2 escape). Stand, + /// Not armed (ambient sync, or already disarmed): ensure no block is shown. + Idle, } -/// Pure SPV-sync block policy (C1/C2). The block is keyed only to the live -/// connection state and the per-episode dismissal flag — there is no separate -/// "armed" flag, so any Connecting/Syncing episode (startup, Connect, or a -/// reconnect) blocks until usable, failed, disconnected, or dismissed. -fn spv_block_step(dismissed: bool, state: OverallConnectionState) -> SpvBlockStep { +/// Pure SPV-sync block policy (F-SPV-A scope gate + C1/C2). The block is **scoped +/// to user-initiated sync** — armed only on startup auto-start and the Connect +/// button — so an ambient reconnect or the SPV engine flipping Synced→Syncing on +/// each new block never hard-blocks a working user. Once an armed episode reaches +/// a terminal state it disarms and stays disarmed until the next Connect/startup. +fn spv_block_step(armed: bool, dismissed: bool, state: OverallConnectionState) -> SpvBlockStep { use OverallConnectionState as S; + if !armed { + return SpvBlockStep::Idle; + } match state { - // Usable, failed (banner surfaces the error), or dropped: the episode is - // over — lower and re-arm for the next sync. - S::Synced | S::Error | S::Disconnected => SpvBlockStep::Release, - // Still working: block unless the user is waiting in the background. - S::Connecting | S::Syncing => { + // Terminal for an armed episode: lower and disarm (banner surfaces Error). + S::Synced | S::Error => SpvBlockStep::Disarm, + // Still getting connected/synced for this episode: block unless the user + // is waiting in the background. Disconnected stays blocking while armed — + // it just means we are still trying to connect. + S::Connecting | S::Syncing | S::Disconnected => { if dismissed { SpvBlockStep::Stand } else { @@ -231,17 +240,21 @@ pub struct AppState { previous_connection_state: Option, /// Handle to the current connection status banner, if one is displayed connection_banner_handle: Option, - /// The blocking progress overlay raised while SPV chain sync is in progress - /// (Connecting/Syncing), hard-blocking the UI until the chain becomes usable - /// (Synced) / fails (Error) / drops (Disconnected), or the user dismisses it. - /// The overlay's first real adopter (PR #863 wiring). See + /// The blocking progress overlay raised while a **user-initiated** SPV sync + /// (startup auto-start / Connect) is getting connected, hard-blocking the UI + /// until the chain becomes usable (Synced) or fails (Error), or the user + /// dismisses it. The overlay's first real adopter (PR #863 wiring). See /// [`Self::update_spv_overlay`]. spv_overlay: Option, + /// Whether a startup/Connect sync episode is **armed** for blocking (F-SPV-A + /// scope gate). Armed on boot auto-start and on the Connect button; disarmed + /// when the episode reaches a terminal state. Ambient reconnects / per-block + /// catch-up are not armed, so they never hard-block a working user. + spv_block_armed: bool, /// Set when the user clicks the overlay's "Continue in the background" escape /// (C1/C2 — SPV sync is unbounded, so the block must never trap the user). The - /// block stays down for the rest of *this* sync episode; sync continues in the - /// background. Reset when the episode ends (Synced/Error/Disconnected) so a - /// fresh sync blocks again. + /// block stays down for the rest of *this* armed episode; sync continues in + /// the background. Reset when the episode disarms (Synced/Error). spv_overlay_dismissed: bool, /// Handle to the current data-migration banner, if one is displayed. /// Kept so per-frame reconciliation can update text in place @@ -736,6 +749,9 @@ impl AppState { previous_connection_state: None, connection_banner_handle: None, spv_overlay: None, + // Arm the block for the boot SPV sync when it auto-starts (F-SPV-A: + // scoped to user-initiated sync, not ambient reconnect). + spv_block_armed: boot_auto_start_spv, spv_overlay_dismissed: false, migration_banner_handle: None, last_migration_state: None, @@ -961,6 +977,7 @@ impl AppState { // bookkeeping so its handle never goes stale against the cleared `ctx.data`. ProgressOverlay::clear_all_global(app_context.egui_ctx()); self.spv_overlay = None; + self.spv_block_armed = false; self.spv_overlay_dismissed = false; for screen in self.main_screens.values_mut() { @@ -1116,34 +1133,41 @@ impl AppState { } /// Drive the blocking SPV-sync overlay each frame (Task 9 — the overlay's - /// first real adopter, PR #863 wiring). Hard-block the UI while the active - /// context's chain sync is Connecting/Syncing, showing the live per-phase - /// summary and a "Step N of 5" counter, and lower it when the chain becomes - /// usable (Synced), fails (Error), or drops (Disconnected). + /// first real adopter, PR #863 wiring). Hard-block the UI while a **armed** + /// (user-initiated: startup auto-start / Connect) sync is getting connected, + /// showing a plain please-wait sentence and a jargon-free "Step N of 5" + /// counter, and lower + DISARM it when the chain becomes usable (Synced) or + /// fails (Error). + /// + /// F-SPV-A: the block is scoped to user-initiated sync, so an ambient + /// reconnect or the SPV engine flipping Synced→Syncing on each new block never + /// hard-blocks a working user — once disarmed it stays disarmed. /// - /// C1/C2 contract: SPV sync is **unbounded** — with no peers it stays + /// C2 contract: SPV sync is **unbounded** — with no peers it stays /// Connecting/Syncing forever with no terminal signal — so a button-less block /// would trap the user. The block therefore carries a "Continue in the /// background" escape ([`SPV_CONTINUE_BACKGROUND_ACTION`]); clicking it lowers /// the block while sync proceeds safely in the background (read-only — nothing - /// is stranded). It also always lowers on its own when the episode ends. + /// is stranded). /// /// Raises the overlay at most once per episode (then updates content in place /// via the handle), so it never `show_global`s every frame. fn update_spv_overlay(&mut self, ctx: &egui::Context, app_context: &Arc) { let cs = app_context.connection_status(); let state = cs.overall_state(); - match spv_block_step(self.spv_overlay_dismissed, state) { + match spv_block_step(self.spv_block_armed, self.spv_overlay_dismissed, state) { SpvBlockStep::Block => { - let progress = cs.spv_sync_progress(); - let description = progress - .as_ref() - .map(spv_phase_summary) - .unwrap_or_else(|| SPV_CONNECTING_DESCRIPTION.to_string()); - let step = progress.as_ref().and_then(spv_phase_step); + // F-SPV-B: plain, jargon-free copy — the determinate granularity is + // the "Step N of 5" counter, NOT raw phase names / heights / %. + let step = cs.spv_sync_progress().as_ref().and_then(spv_phase_step); + let description = if step.is_some() { + SPV_SYNCING_DESCRIPTION + } else { + SPV_CONNECTING_DESCRIPTION + }; if self.spv_overlay.is_none() { let mut config = OverlayConfig::new() - .with_description(&description) + .with_description(description) .with_secondary_button( SPV_CONTINUE_BACKGROUND_ACTION, "Continue in the background", @@ -1153,7 +1177,7 @@ impl AppState { } self.spv_overlay.raise(ctx, "", config); } else if let Some(handle) = &self.spv_overlay { - handle.set_description(&description); + handle.set_description(description); match step { Some(n) => { handle.set_step(n, 5); @@ -1164,14 +1188,21 @@ impl AppState { } } } - SpvBlockStep::Release => { - // Episode over: lower and re-arm so a fresh sync blocks again (C1). + SpvBlockStep::Disarm => { + // Armed episode ended (Synced/Error): lower and disarm so ambient + // Connecting/Syncing never re-blocks (F-SPV-A). Re-arm the escape + // for the next user-initiated sync. self.spv_overlay.take_and_clear(); + self.spv_block_armed = false; self.spv_overlay_dismissed = false; } SpvBlockStep::Stand => { // User chose to continue in the background: stay lowered, but keep - // the dismissal so we don't re-raise within this episode (C2). + // the episode armed + dismissed so we don't re-raise within it (C2). + self.spv_overlay.take_and_clear(); + } + SpvBlockStep::Idle => { + // Not armed (ambient sync, or already disarmed): never block. self.spv_overlay.take_and_clear(); } } @@ -1333,11 +1364,18 @@ impl AppState { self.active_secret_prompt = active.then(ActivePrompt::test_stub); } + /// Test seam (Task 9 / F-SPV-A): arm a user-initiated SPV-sync block episode, + /// as the boot auto-start and the Connect button do. + #[cfg(feature = "testing")] + pub fn test_arm_spv_block(&mut self) { + self.spv_block_armed = true; + self.spv_overlay_dismissed = false; + } + /// Test seam (Task 9): run the REAL `update_spv_overlay` driver once against /// the active context's (forced) connection state, in isolation from the - /// throttled frame loop. Lets a kittest assert the block raises while - /// connecting, lowers when usable/failed/disconnected, and lowers on the - /// "continue in the background" escape. + /// throttled frame loop. Lets a kittest assert that an armed episode blocks, + /// disarms on a terminal state, and that ambient (un-armed) sync never blocks. #[cfg(feature = "testing")] pub fn test_drive_spv_overlay(&mut self, ctx: &egui::Context) { let app_context = self.current_app_context().clone(); @@ -1836,12 +1874,18 @@ impl App for AppState { self.set_main_screen(root_screen_type); } AppAction::StartSpv => { + // Arm the SPV-sync block for this user-initiated Connect (a + // fresh episode — re-arm the escape). The block conveys the + // "connecting" state, so no separate Info banner is set here + // (F-SPV-E: a dropped Info-banner handle could not be cleared + // by the overlay's banner suppression). + self.spv_block_armed = true; + self.spv_overlay_dismissed = false; let app_ctx = self.current_app_context().clone(); let sender = self.task_result_sender.clone(); let egui_ctx = ctx.clone(); const CONNECTING_MSG: &str = "Connecting to the network. This may take a moment."; - MessageBanner::set_global(ctx, CONNECTING_MSG, MessageType::Info); self.subtasks.spawn_sync("spv_manual_start", async move { // The chokepoint already flips the SPV indicator to Error // on failure; here we additionally swap the "Connecting…" @@ -1971,40 +2015,66 @@ mod migration_banner_tests { mod spv_overlay_tests { use super::*; - /// Connecting/Syncing and not dismissed → hard block. + const ALL_STATES: [OverallConnectionState; 5] = [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + OverallConnectionState::Synced, + OverallConnectionState::Error, + ]; + + /// F-SPV-A — UN-armed (ambient sync, or already disarmed): NEVER block, for + /// every state and dismissal. This is the regression guard: a mid-session + /// reconnect or per-block Synced→Syncing flip must not hard-block. + #[test] + fn unarmed_never_blocks() { + for dismissed in [false, true] { + for state in ALL_STATES { + assert_eq!( + spv_block_step(false, dismissed, state), + SpvBlockStep::Idle, + "un-armed {state:?} (dismissed={dismissed}) must not block" + ); + } + } + } + + /// Armed + getting-connected (Connecting/Syncing/Disconnected) + not dismissed + /// → hard block. #[test] - fn connecting_or_syncing_blocks_when_not_dismissed() { + fn armed_blocks_while_getting_connected() { for state in [ + OverallConnectionState::Disconnected, OverallConnectionState::Connecting, OverallConnectionState::Syncing, ] { - assert_eq!(spv_block_step(false, state), SpvBlockStep::Block); + assert_eq!(spv_block_step(true, false, state), SpvBlockStep::Block); } } - /// C2 escape — dismissed during Connecting/Syncing → Stand (no block), but the - /// episode is NOT ended (sync keeps running; the user is just not trapped). + /// C2 escape — armed + dismissed + getting-connected → Stand (no block, episode + /// kept armed so sync keeps running and the user is just not trapped). #[test] - fn dismissed_stands_down_without_ending_episode() { + fn armed_dismissed_stands_down_without_disarming() { for state in [ + OverallConnectionState::Disconnected, OverallConnectionState::Connecting, OverallConnectionState::Syncing, ] { - assert_eq!(spv_block_step(true, state), SpvBlockStep::Stand); + assert_eq!(spv_block_step(true, true, state), SpvBlockStep::Stand); } } - /// C1 — episode-ending states (usable, failed, disconnected) always release - /// and re-arm, regardless of whether the user had dismissed the block. + /// C1 / F-SPV-A — armed + terminal (Synced/Error) → Disarm, regardless of + /// dismissal: lower and disarm so ambient sync afterwards never re-blocks. #[test] - fn terminal_states_always_release() { + fn armed_terminal_state_disarms() { for dismissed in [false, true] { for state in [ OverallConnectionState::Synced, OverallConnectionState::Error, - OverallConnectionState::Disconnected, ] { - assert_eq!(spv_block_step(dismissed, state), SpvBlockStep::Release); + assert_eq!(spv_block_step(true, dismissed, state), SpvBlockStep::Disarm); } } } @@ -2018,4 +2088,20 @@ mod spv_overlay_tests { "spv:sync:continue_background" ); } + + /// F-SPV-B — the block descriptions are jargon-free complete sentences (no + /// "SPV"/"headers"/"masternodes"/raw heights/percentages). + #[test] + fn descriptions_are_jargon_free_sentences() { + for desc in [SPV_CONNECTING_DESCRIPTION, SPV_SYNCING_DESCRIPTION] { + assert!(desc.ends_with('.'), "`{desc}` must be a complete sentence"); + let lower = desc.to_lowercase(); + for jargon in ["header", "masternode", "filter", "spv", "rpc", "%", "/"] { + assert!( + !lower.contains(jargon), + "`{desc}` leaks blockchain jargon `{jargon}` to the Everyday User" + ); + } + } + } } diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 93c6fa24d..eb616755a 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -1198,14 +1198,15 @@ fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { // ── Task 9: SPV-sync blocking overlay (startup + Connect) ──────────────────── -/// Task 9 — the SPV-sync block raises while Connecting/Syncing, lowers -/// automatically when the chain becomes usable (C1), lowers on the "Continue in -/// the background" escape without re-raising in the same episode (C2), and -/// re-raises for a fresh sync episode. Drives the REAL `AppState::update_spv_overlay` -/// against a forced connection state. +/// Task 9 / F-SPV-A — the SPV-sync block is SCOPED to a user-initiated (armed) +/// episode: an armed Connecting/Syncing blocks; an UN-armed (ambient) reconnect or +/// per-block Synced→Syncing flip does NOT block; an armed episode disarms on a +/// terminal state and stays disarmed; the escape lowers it without re-raising; and +/// only a fresh armed episode re-blocks. Jargon-free copy (F-SPV-B). Drives the +/// REAL `AppState::update_spv_overlay` against a forced connection state. #[cfg(feature = "testing")] #[test] -fn task9_spv_overlay_blocks_lowers_on_synced_and_on_escape() { +fn task9_spv_overlay_armed_scope_disarm_and_escape() { use dash_evo_tool::context::connection_status::OverallConnectionState; crate::support::with_isolated_data_dir(|| { let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); @@ -1222,14 +1223,21 @@ fn task9_spv_overlay_blocks_lowers_on_synced_and_on_escape() { let app_context = app.current_app_context().clone(); let set_state = |s| app_context.connection_status().set_overall_state(s); - // Connecting → the block raises (no separate "armed" flag; it keys off state). + // F-SPV-A regression guard: an UN-armed Connecting must NOT block (ambient). set_state(OverallConnectionState::Connecting); app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "an un-armed (ambient) connecting sync must NOT hard-block the user" + ); + + // Arm a user-initiated episode (startup / Connect) → Connecting blocks. + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); assert!( ProgressOverlay::has_global(&harness.ctx), - "the block raises while connecting" + "an armed connecting sync raises the block" ); - // The escape button is a SECONDARY button labelled "Continue in the background". harness.step(); harness.step(); harness.step(); @@ -1237,30 +1245,41 @@ fn task9_spv_overlay_blocks_lowers_on_synced_and_on_escape() { harness .query_by_label("Continue in the background") .is_some(), - "the escape button renders on the sync block" + "the secondary 'Continue in the background' escape button renders" + ); + // F-SPV-B: no blockchain jargon leaks into the description. + assert!( + harness.query_by_label_contains("Headers:").is_none() + && harness.query_by_label_contains("Masternodes:").is_none(), + "the block description must be jargon-free" ); - // C1 — usable: the block lowers on its own. + // C1 / F-SPV-A: Synced → lowers AND DISARMS. set_state(OverallConnectionState::Synced); app.test_drive_spv_overlay(&harness.ctx); assert!( !ProgressOverlay::has_global(&harness.ctx), - "the block lowers automatically once synced" + "the block lowers when synced" + ); + + // After disarm, ambient Connecting/Syncing (reconnect, per-block catch-up) + // must NOT re-block — the core F-SPV-A fix. + set_state(OverallConnectionState::Syncing); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "ambient syncing after the episode disarmed must NOT re-block" ); - // A fresh episode (back to Connecting) re-raises the block. + // C2 escape: arm a fresh episode, block, click escape → lowers and stays + // down for the rest of THIS episode even though sync is still in progress. + app.test_arm_spv_block(); set_state(OverallConnectionState::Connecting); app.test_drive_spv_overlay(&harness.ctx); harness.step(); harness.step(); harness.step(); - assert!( - ProgressOverlay::has_global(&harness.ctx), - "a fresh sync episode re-raises the block" - ); - - // C2 — click the escape: the block lowers even though sync is still - // (unbounded) in progress, so the user is never trapped... + assert!(ProgressOverlay::has_global(&harness.ctx)); harness.get_by_label("Continue in the background").click(); harness.step(); app.test_drive_spv_overlay(&harness.ctx); @@ -1268,21 +1287,26 @@ fn task9_spv_overlay_blocks_lowers_on_synced_and_on_escape() { !ProgressOverlay::has_global(&harness.ctx), "the escape lowers the block (user never trapped)" ); - // ...and it stays down for the rest of THIS episode (still Connecting). app.test_drive_spv_overlay(&harness.ctx); assert!( !ProgressOverlay::has_global(&harness.ctx), "the block is not re-raised within the dismissed episode" ); - // A new episode (Disconnected resets the dismissal, then Connecting) re-raises. + // Only a fresh ARMED episode re-blocks; an ambient one still does not. set_state(OverallConnectionState::Disconnected); app.test_drive_spv_overlay(&harness.ctx); set_state(OverallConnectionState::Connecting); app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "ambient connecting (no fresh arm) must NOT block" + ); + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); assert!( ProgressOverlay::has_global(&harness.ctx), - "after the episode ends and a new sync begins, the block re-raises" + "a fresh armed episode re-blocks" ); }); } From b7268716073c87b082864f23c3925918ecf0f144 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 17 Jun 2026 23:43:59 +0200 Subject: [PATCH 16/22] =?UTF-8?q?fix(overlay):=20address=20review=20findin?= =?UTF-8?q?gs=20=E2=80=94=20deterministic=20elapsed=20test,=20SPV=20phase-?= =?UTF-8?q?count=20constant,=20input-claim=20hardening,=20doc=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the 2.1s wall-clock sleep in tc_ovl_013b with the deterministic `backdate` clock seam (gated behind `testing`), mirroring tc_ovl_047b — zero wall-clock waiting; asserts the elapsed readout counts up to a concrete 2s. - Add `SPV_SYNC_PHASE_COUNT` next to `spv_phase_step` as the single source of truth for the "Step N of 5" total; reference it at both app.rs call sites and guard the max step with a `debug_assert!` so it cannot silently drift. - Delete the misplaced orphan-sweeper paragraph from `claim_overlay_input`'s doc (it belongs to `drain_overlay_actions`, which already carries it). - Reconcile the `Order::Middle` → `Order::Foreground` doc drift: supersession callouts in the dev plan §4.2/§4.3 and the kittest module doc, citing SEC-002. - Drop the dead `CONNECTING_MSG`/`replace_global` swap in the StartSpv failure path (the "Connecting…" banner was removed in F-SPV-E) for a plain `set_global(...).with_details(e)`; fix the now-stale comment. - Extend `claim_input`'s per-frame strip to also drop Backspace, Delete, Home, End, PageUp, PageDown and the Copy/Cut/Paste clipboard events; add a kittest locking the new classes via event survival + the field-beneath contract. - Strengthen the SEC-001 lifecycle rustdoc on `show_global` / `show_global_spinner_only` (button-less blocks need a frame-driven reconcile owner or an escape; the watchdog only logs). - Nits: UX-001 "developer warning" → "developer error"; "while a armed" → "while an armed". Add deferred TODOs (SEC-002-pointer, SEC-001, RUST-006). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../03-dev-plan.md | 10 ++ docs/user-stories.md | 2 +- src/app.rs | 33 +++--- src/context/connection_status.rs | 34 ++++-- src/ui/components/progress_overlay.rs | 36 +++++- tests/kittest/progress_overlay.rs | 112 ++++++++++++++++-- 6 files changed, 182 insertions(+), 45 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index eb97bd38d..41dde1a6c 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -349,6 +349,12 @@ On network switch, clear the overlay alongside banners (mirror `MessageBanner::c ### 4.2 Layer ordering (R-1) — the decisive part +> **Superseded — `Order::Foreground`, not `Order::Middle`.** The shipped overlay paints its dim, +> sink, and card on `Order::Foreground` (SEC-002: above Foreground popups like ComboBox/autocomplete +> that would otherwise float over a `Middle` block); the secret prompt is raised to match and +> rendered later, so it still wins above the overlay. Treat `progress_overlay.rs` (SEC-002) as the +> source of truth for layer ordering — the `Order::Middle` references below are the original plan. + egui paints, within one `Order`, in area-creation order; an interacted/focused area is raised to the top of its order. The live secret prompt is an `egui::Window` (default `Order::Middle`) that `request_focus()`es its input (`passphrase_modal.rs:139-172`). @@ -370,6 +376,10 @@ that `request_focus()`es its input (`passphrase_modal.rs:139-172`). ### 4.3 Dim plane + input-blocking technique (FR-8, NFR-1) +> **Superseded — `Order::Foreground`, not `Order::Middle`.** The dim, pointer sink, and card below +> ship on `Order::Foreground` (SEC-002), not `Order::Middle`. Treat `progress_overlay.rs` as the +> source of truth for the layer the dim/sink/card render on. + `Ui::set_enabled(false)` is **deprecated in egui 0.33** (confirmed memory; test-spec AC-8.2). Use a top input-capturing layer instead — the same shape the passphrase modal already uses (`layer_painter` + a centered window), extended with a full-window interactable sink: diff --git a/docs/user-stories.md b/docs/user-stories.md index 2af1eda77..7a9989f72 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1135,7 +1135,7 @@ As a user, while a long operation that is unsafe to interrupt is running (broadc - A full-window dimming overlay with an indeterminate spinner and an optional "Step N of M" counter and description appears while the operation runs, and lowers automatically when it finishes (success or error). - All interaction beneath the block is suppressed: pointer clicks hit a sink, and keyboard/text input is claimed at frame start so nothing reaches a focused field beneath (FR-8 / QA-001). The block is never dismissable by Esc, Enter, Space, or Tab. - The block yields to a passphrase prompt: when a secret prompt is shown above the overlay it keeps the keyboard (Enter/Esc/Tab) so the user can still authenticate or cancel (SEC-004). -- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer warning. For these unsafe-to-interrupt operations there is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. _(Exception: the startup/Connect SPV-sync block of UX-002 is unbounded but read-only, so it ships an always-visible "Continue in the background" escape instead.)_ +- Honest escalation, never a fake exit: after 30 s a calm "This is taking longer than usual." line appears; after 120 s with no progress it escalates to "This is taking much longer than expected…" and logs a one-shot developer error. For these unsafe-to-interrupt operations there is no background/dismiss button — the safety guarantee is that every blocked operation is bounded and always lowers the block through the normal path. _(Exception: the startup/Connect SPV-sync block of UX-002 is unbounded but read-only, so it ships an always-visible "Continue in the background" escape instead.)_ ### UX-002: Blocking SPV-sync overlay with a "continue in the background" escape [Implemented] **Persona:** Alex, Priya, Jordan diff --git a/src/app.rs b/src/app.rs index 8729771fb..4896f9974 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,9 @@ use crate::backend_task::error::TaskError; use crate::backend_task::migration::MigrationTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::context::connection_status::{ConnectionStatus, OverallConnectionState, spv_phase_step}; +use crate::context::connection_status::{ + ConnectionStatus, OverallConnectionState, SPV_SYNC_PHASE_COUNT, spv_phase_step, +}; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; #[cfg(not(feature = "testing"))] @@ -1133,7 +1135,7 @@ impl AppState { } /// Drive the blocking SPV-sync overlay each frame (Task 9 — the overlay's - /// first real adopter, PR #863 wiring). Hard-block the UI while a **armed** + /// first real adopter, PR #863 wiring). Hard-block the UI while an **armed** /// (user-initiated: startup auto-start / Connect) sync is getting connected, /// showing a plain please-wait sentence and a jargon-free "Step N of 5" /// counter, and lower + DISARM it when the chain becomes usable (Synced) or @@ -1166,6 +1168,10 @@ impl AppState { SPV_CONNECTING_DESCRIPTION }; if self.spv_overlay.is_none() { + // TODO(RUST-006): provide a keyboard/assistive-tech exit for the + // UNBOUNDED SPV block (hard blocks are intentionally not + // keyboard-activatable per QA-002, but the unbounded SPV case can + // strand keyboard-only users) — pending product decision. let mut config = OverlayConfig::new() .with_description(description) .with_secondary_button( @@ -1173,14 +1179,14 @@ impl AppState { "Continue in the background", ); if let Some(n) = step { - config = config.with_step(n, 5); + config = config.with_step(n, SPV_SYNC_PHASE_COUNT); } self.spv_overlay.raise(ctx, "", config); } else if let Some(handle) = &self.spv_overlay { handle.set_description(description); match step { Some(n) => { - handle.set_step(n, 5); + handle.set_step(n, SPV_SYNC_PHASE_COUNT); } None => { handle.clear_step(); @@ -1335,13 +1341,6 @@ impl AppState { } } - /// Sweep orphaned overlay button-action clicks (A-3): ids whose owning - /// overlay entry is no longer on the stack (the owner cleared or dropped its - /// handle without draining). Screens own dispatch — they drain **their own** - /// clicks at the top of `ui()` via [`OverlayHandle::take_actions`] and match - /// their own colon-namespaced ids, including their own cancellation. This loop - /// only reclaims truly-orphaned ids so they can't accumulate in `ctx.data`; it - /// can never race or pre-empt a live owner, so its position here is safe. /// Claim all keyboard + text input for an active blocking overlay at frame /// start (QA-001) — UNLESS a secret prompt is active above it. The prompt /// renders above the overlay and needs the keyboard (Enter to submit, Esc to @@ -1890,17 +1889,13 @@ impl App for AppState { let app_ctx = self.current_app_context().clone(); let sender = self.task_result_sender.clone(); let egui_ctx = ctx.clone(); - const CONNECTING_MSG: &str = - "Connecting to the network. This may take a moment."; self.subtasks.spawn_sync("spv_manual_start", async move { - // The chokepoint already flips the SPV indicator to Error - // on failure; here we additionally swap the "Connecting…" - // banner for an actionable one, since the user pressed - // Connect and is waiting for explicit feedback. + // The chokepoint already flips the SPV indicator to Error on + // failure; the user pressed Connect and is waiting, so also + // surface an actionable error banner here. if let Err(e) = app_ctx.ensure_wallet_backend_and_start_spv(sender).await { - MessageBanner::replace_global( + MessageBanner::set_global( &egui_ctx, - CONNECTING_MSG, "Could not start network sync. Check your connection and try again.", MessageType::Error, ) diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 622aa33b5..68f6972bf 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -640,28 +640,40 @@ pub fn spv_phase_summary(progress: &SpvSyncProgress) -> String { "syncing...".to_string() } +/// Number of phases in the SPV sync pipeline — the total in the blocking +/// overlay's "Step N of {total}" counter. Single source of truth: shared with +/// the overlay adopter so the displayed total can never drift from the phase +/// count [`spv_phase_step`] actually walks. +pub const SPV_SYNC_PHASE_COUNT: u32 = 5; + /// Map the currently-active SPV sync phase to a 1-based step number for the -/// blocking overlay's "Step N of 5" counter — Headers=1, Masternodes=2, -/// Filter Headers=3, Filters=4, Blocks=5 — or `None` when no phase is actively -/// syncing yet. Mirrors the pipeline order of [`spv_phase_summary`]. +/// blocking overlay's "Step N of {total}" counter — Headers=1, Masternodes=2, +/// Filter Headers=3, Filters=4, Blocks=[`SPV_SYNC_PHASE_COUNT`] — or `None` when +/// no phase is actively syncing yet. Mirrors the pipeline order of +/// [`spv_phase_summary`]. pub fn spv_phase_step(progress: &SpvSyncProgress) -> Option { let is_syncing = |state: SyncState| state == SyncState::Syncing; - if progress.headers().is_ok_and(|p| is_syncing(p.state())) { - Some(1) + let step = if progress.headers().is_ok_and(|p| is_syncing(p.state())) { + 1 } else if progress.masternodes().is_ok_and(|p| is_syncing(p.state())) { - Some(2) + 2 } else if progress .filter_headers() .is_ok_and(|p| is_syncing(p.state())) { - Some(3) + 3 } else if progress.filters().is_ok_and(|p| is_syncing(p.state())) { - Some(4) + 4 } else if progress.blocks().is_ok_and(|p| is_syncing(p.state())) { - Some(5) + SPV_SYNC_PHASE_COUNT } else { - None - } + return None; + }; + debug_assert!( + step <= SPV_SYNC_PHASE_COUNT, + "SPV phase step {step} exceeds SPV_SYNC_PHASE_COUNT {SPV_SYNC_PHASE_COUNT} — bump the constant" + ); + Some(step) } fn pct(current: u32, target: u32) -> u32 { diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index 21493dcd8..eb8e09cae 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -515,6 +515,13 @@ impl ProgressOverlay { /// `ctx.data`. The `description` argument is used unless `config` already /// carries one. /// + /// **Lifecycle (SEC-001):** a button-less app-level block has no automatic + /// teardown — the no-progress watchdog only *logs*, it never lowers the block. + /// A button-less block MUST therefore either be driven by a frame-driven + /// reconcile owner that lowers it when the work ends (the reference pattern is + /// the SPV adopter, `AppState::update_spv_overlay`), or carry an escape + /// button; a leaked or forgotten handle strands the UI with no way out. + /// /// SEC-006: the `description` (and any button `label`/`id`) is user-visible /// and written to logs on show — never pass secrets, passphrases, or PII. pub fn show_global( @@ -546,6 +553,11 @@ impl ProgressOverlay { } /// Convenience: a spinner-only block with no text, counter, or buttons. + /// + /// As a button-less block it has no escape, so the SEC-001 lifecycle rule from + /// [`show_global`](Self::show_global) applies in full: drive it from a + /// frame-driven reconcile owner (e.g. `AppState::update_spv_overlay`) that + /// lowers it when the work ends — a leaked handle has no automatic teardown. pub fn show_global_spinner_only(ctx: &egui::Context) -> OverlayHandle { Self::show_global(ctx, "", OverlayConfig::default()) } @@ -604,8 +616,10 @@ impl ProgressOverlay { /// - releasing text-edit focus from any field beneath (so it stops drawing a /// caret and consuming text — affects only text widgets, never an overlay /// button), and - /// - stripping `Event::Text` and the navigation/confirm keys (Tab, Enter, - /// Escape, Space, arrows) from `i.events` so nothing beneath observes them. + /// - stripping `Event::Text`, the clipboard events (Copy/Cut/Paste), and the + /// navigation/confirm/edit keys (Tab, Enter, Escape, Space, arrows, + /// Backspace, Delete, Home, End, PageUp, PageDown) from `i.events` so + /// nothing beneath observes them. /// /// A hard block is never keyboard-dismissable or keyboard-activatable. The /// buttoned case (post-T7) re-grants its own buttons' navigation via the @@ -629,6 +643,9 @@ impl ProgressOverlay { !matches!( e, egui::Event::Text(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::Tab | egui::Key::Enter @@ -637,13 +654,22 @@ impl ProgressOverlay { | egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::ArrowLeft - | egui::Key::ArrowRight, + | egui::Key::ArrowRight + | egui::Key::Backspace + | egui::Key::Delete + | egui::Key::Home + | egui::Key::End + | egui::Key::PageUp + | egui::Key::PageDown, pressed: true, .. } ) }); }); + // TODO(SEC-002-pointer): claim pointer press/click/drag at frame start + // (analogue of the keyboard QA-001 frame-start claim) to close the + // one-frame click-through on the raising frame. } /// Render the topmost entry. Call once per frame from `AppState::update`, @@ -681,6 +707,10 @@ impl ProgressOverlay { "Blocking overlay has shown no progress for over 2 minutes — \ likely a leaked handle or an un-bounded operation" ); + // TODO(SEC-001): make the no-progress watchdog actionable (auto-attach + // an escape or enforce a frame-driven reconcile owner for button-less + // blocks) — pending product decision; conflicts with the no-built-in- + // cancel directive. } let dark_mode = ctx.style().visuals.dark_mode; diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index eb616755a..2fbe5f68f 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -16,8 +16,9 @@ //! and by the fact these synchronous tests compile and run. //! - TC-OVL-031 (render seam): `ProgressOverlay::render_global` is called from //! `AppState::update` after panels, not inside `island_central_panel`. -//! - TC-OVL-032 (z-order above banners): the overlay paints on `Order::Middle`; -//! banners paint on `Order::Background` inside the central panel. +//! - TC-OVL-032 (z-order above banners): the overlay paints on `Order::Foreground` +//! (SEC-002, above Foreground popups); banners paint on `Order::Background` +//! inside the central panel. //! - TC-OVL-040 / TC-OVL-045 (log-once): covered by the inline unit tests in //! `src/ui/components/progress_overlay.rs` (`render_logs_once_then_marks_logged`); //! the concurrent-request warning is emitted once in `show_global`, never per frame. @@ -27,6 +28,7 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; +#[cfg(feature = "testing")] use std::time::Duration; use dash_evo_tool::ui::MessageType; @@ -260,10 +262,14 @@ fn tc_ovl_013a_elapsed_off_by_default() { } /// TC-OVL-013 (Part B) — when enabled the elapsed readout shows and counts up. +/// Uses the deterministic clock seam (`backdate`) instead of a wall-clock sleep, +/// mirroring `tc_ovl_047b_threshold_reveals_via_clock_seam`. QA-008: assert the +/// readout advanced to a concrete 2s, not merely past 0s. +#[cfg(feature = "testing")] #[test] fn tc_ovl_013b_elapsed_on_counts_up() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::show_global( &harness.ctx, "Slow operation.", OverlayConfig::new().with_elapsed(), @@ -271,18 +277,18 @@ fn tc_ovl_013b_elapsed_on_counts_up() { harness.step(); assert!(harness.query_by_label("Elapsed: 0s").is_some()); - // Real wall-clock elapsed (Instant-based) — counts up, never down. QA-008: - // assert it advanced to at least 2s, not merely past 0s. - std::thread::sleep(Duration::from_millis(2100)); + // Shift this entry's clock 2s into the past via the test seam, then re-render: + // the readout counts up to 2s deterministically, with zero wall-clock waiting. + handle.backdate(Duration::from_secs(2)); harness.step(); assert!( - harness.query_by_label("Elapsed: 0s").is_none() - && harness.query_by_label("Elapsed: 1s").is_none(), - "the readout advanced to at least 2s" + harness.query_by_label("Elapsed: 2s").is_some(), + "the readout advanced to 2s via the clock seam" ); assert!( - harness.query_by_label_contains("Elapsed:").is_some(), - "the readout persists and never disappears or counts down" + harness.query_by_label("Elapsed: 0s").is_none() + && harness.query_by_label("Elapsed: 1s").is_none(), + "the readout counts up, never down, and never stalls at 0s" ); } @@ -1047,6 +1053,90 @@ fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { ); } +// SEC-002 (additive hardening): `claim_input` also strips edit keys +// (Backspace/Delete/Home/End/PageUp/PageDown) and clipboard events +// (Copy/Cut/Paste) at frame start, so a focused field beneath a block is neither +// edited nor pasted into. This locks the new classes via event survival (fails +// if the strip is removed) plus the field-beneath behavioral contract. +#[test] +fn qa_buttonless_overlay_strips_edit_and_clipboard_events() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let survived = Rc::new(Cell::new(false)); + let survived_ui = Rc::clone(&survived); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let leaked = ui.ctx().input(|i| { + i.events.iter().any(|e| { + matches!( + e, + egui::Event::Paste(_) + | egui::Event::Key { + key: egui::Key::Backspace | egui::Key::Delete, + pressed: true, + .. + } + ) + }) + }); + survived_ui.set(leaked); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx()); + }); + + // Focus the field and type real content while idle (claim_input is a no-op + // with no overlay), so egui holds a live cursor at the end of "keep". + harness.step(); + harness + .get_by_role(egui::accesskit::Role::TextInput) + .focus(); + harness.step(); + harness + .input_mut() + .events + .push(egui::Event::Text("keep".to_string())); + harness.step(); + assert_eq!( + text.borrow().as_str(), + "keep", + "typing must reach the focused field while no overlay is up" + ); + + // Raise a button-less block over the focused, populated field. + let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + harness.step(); + + // Inject edit + clipboard events; claim_input must strip them all. + for key in [egui::Key::Backspace, egui::Key::Delete] { + harness.input_mut().events.push(egui::Event::Key { + key, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }); + } + harness + .input_mut() + .events + .push(egui::Event::Paste("INJECT".to_string())); + harness.step(); + + assert!( + !survived.get(), + "claim_input must strip Backspace/Delete/Paste while a block is up" + ); + assert_eq!( + text.borrow().as_str(), + "keep", + "edit/clipboard events reached a field beneath a button-less overlay: {:?}", + text.borrow() + ); +} + // ── Cross-finding reconciliations (lead brief) ────────────────────────────── /// Reconciliation #1 (SEC-004 / Diziet F-1) — while a secret prompt is active the From 2186429405a5c8800079a82767db4d7cf40bbb9e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:38:38 +0200 Subject: [PATCH 17/22] fix(overlay): close one-frame SPV block gap, fix slow-phase watchdog, align API to MessageBanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three changes to the blocking progress overlay + SPV-sync hard-block: A — Close the one-frame interactive gap. `update_spv_overlay` now runs at the top of `AppState::update`, BEFORE `claim_overlay_input`, the visible screen `ui()`, and `render_global`. A freshly-armed episode therefore raises, claims input, AND paints on the same frame; previously the block was raised only after `render_global`, leaving the frame right after Connect/arming fully interactive (effective at frame N+2). The connection banner still reads the block state afterwards, so its Connecting/Syncing suppression is unchanged. B — Stop the 120s no-progress watchdog from falsely escalating on slow phases. A single SPV phase running >120s (e.g. Headers on a slow link) wrote a constant (description, step), so `log_overlay_state` never reset `last_progress_at` and the watchdog tripped — swapping to the STUCK copy and firing the one-shot dev-error, the exact false signal the SPV escape was meant to avoid. A hidden, monotonic `progress_token` (step in the high 32 bits, advancing height in the low 32) is threaded from `ConnectionStatus` into the overlay; an advancing token resets the watchdog even when the shown (description, step) is unchanged. The token is NEVER rendered — copy is byte-for-byte unchanged and the jargon-free test stays green. Distinct from TODO(SEC-001), which is left in place. C — Align the overlay public API toward MessageBanner so migrating from the banner is a name-for-name swap. One-way (overlay → banner), no capability loss: with_button(id, label) -> with_action(label, action_id) with_secondary_button(id, label) -> with_secondary_action(label, action_id) show_global(...) -> set_global(...) (return type kept) show_global_spinner_only(...) -> set_global_spinner_only(...) `OptionOverlayExt::raise` keeps its name: renaming to `replace` (the banner analogue) would be shadowed by the inherent `Option::replace`, so every `slot.replace(ctx, desc, config)` call would fail with E0061 (verified). A doc note records why. `render_global`, `claim_input`, the watchdog, `OverlayConfig`, and all handle progress methods are untouched. Rustdoc, the README catalog row, and the design-doc API references are updated to the new names; the banner's own `MessageBanner::show_global(ui)` render path is left alone. Tests: new real-AppState kittest for the one-frame gap (same-frame paint), new backdate kittest + unit tests for the token-driven watchdog reset, and a `spv_progress_token` monotonicity unit test. fmt + clippy clean; kittest 138 passed; lib 926 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 12 +- .../02-test-spec.md | 44 +-- .../03-dev-plan.md | 28 +- src/app.rs | 37 ++- src/context/connection_status.rs | 93 +++++-- src/ui/components/README.md | 2 +- src/ui/components/progress_overlay.rs | 261 +++++++++++++----- tests/kittest/progress_overlay.rs | 228 ++++++++++----- 8 files changed, 500 insertions(+), 205 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index 9abcfd00f..6ea8c19d2 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -10,7 +10,7 @@ > **Supersession callout (post-outage redesign + QA-wave addendum).** Parts of this spec describe > a first-class **Cancel** control that no longer exists. The shipped design replaces it with a -> **generic button facility** (`with_button` / `with_secondary_button`, clicks delivered keyed to +> **generic button facility** (`with_action` / `with_secondary_action`, clicks delivered keyed to > the owning screen), and adds a no-progress **watchdog** and a frame-start **`claim_input`** total > block. Where this document and the redesign disagree, **`03-dev-plan.md`'s post-outage note, > `04-design-addendum.md`, and the code (`src/ui/components/progress_overlay.rs`) win.** Items known @@ -43,7 +43,7 @@ This spec defines a **full-screen blocking progress overlay**: a sibling capabil beneath it, and shows a "please wait" message with an **indeterminate spinner (no ETA)**, an **optional step counter** (`Step {current} of {total}`), and **optional action buttons** (at minimum Cancel). It is dismissed programmatically when the operation completes, or by an -optional Cancel button. _(Superseded — see top banner: buttons are generic (`with_button`), there +optional Cancel button. _(Superseded — see top banner: buttons are generic (`with_action`), there is no built-in Cancel, and Esc/Tab/Enter/Space are swallowed; dismissal is programmatic.)_ **Critical invariant (learned the hard way — PR860):** the overlay is a *visual + input* @@ -140,7 +140,7 @@ overlay's own controls. **AC-8.1** Pointer clicks/drags on any region outside the overlay's own buttons have no effect on the UI beneath. **AC-8.2** Keyboard input (Tab, Enter, typing) does not reach widgets beneath the overlay. (Do not rely on `Ui::set_enabled()` — deprecated in egui 0.33; use a top input-capturing layer instead.) **AC-8.3** The block covers the *entire* window, including top and left panels — therefore the overlay renders at the `AppState` level, not inside `island_central_panel()` (which only wraps central content). See §3. -**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. _(Superseded — see top banner: there is no built-in Cancel; dismissal is programmatic. A screen MAY add a generic button (`with_button`) that triggers its own teardown.)_ +**AC-8.4** Clicking the dimmed backdrop does **not** dismiss the overlay (unlike a passphrase modal) — a blocking progress overlay is not click-outside-to-cancel; dismissal is programmatic or via an explicit Cancel button only. _(Superseded — see top banner: there is no built-in Cancel; dismissal is programmatic. A screen MAY add a generic button (`with_action`) that triggers its own teardown.)_ ### FR-9 — Coexistence with MessageBanner (z-order + hand-off) **AC-9.1** The overlay renders **above** all `MessageBanner` banners (banners live inside the island content area at a background layer; the overlay sits on a top layer). The overlay wins z-order. @@ -233,7 +233,7 @@ trap). ### NFR-6 — Cheap render **AC:** When no overlay is active, the render call is an early-out reading one `ctx.data` slot -(mirroring `show_global`'s empty check). No allocation on the idle path. +(mirroring `set_global`'s empty check). No allocation on the idle path. --- @@ -393,7 +393,7 @@ The overlay should **copy the proven *patterns***, keeping its own type: 1. **Global state in egui `ctx.data` temp storage**, keyed by a dedicated id (e.g. `__global_progress_overlay`) — same mechanism as `BANNER_STATE_ID`. 2. **A lifecycle `OverlayHandle`** mirroring `BannerHandle` (`set_description`, `set_step`, - `with_button` / `with_secondary_button`, `clear`), all returning `Option` and no-op on a + `with_action` / `with_secondary_action`, `clear`), all returning `Option` and no-op on a dismissed overlay. _(Superseded: no `with_cancel` — buttons are generic; see the dev-plan post-outage note and the code.)_ 3. **An action-id queue** drained by the app loop. As shipped (addendum §2) the queue is **keyed** @@ -408,7 +408,7 @@ The overlay should **copy the proven *patterns***, keeping its own type: ### Placement Per the DET module-placement policy, it renders egui → it is a **component** in -`src/ui/components/` (e.g. `progress_overlay.rs`), with its `show_global(ctx)` invoked from +`src/ui/components/` (e.g. `progress_overlay.rs`), with its `set_global(ctx)` invoked from `AppState::update()` near `render_secret_prompt`. Non-rendering helpers stay out of it. A thin shared helper for the `ctx.data` get/set/clear plumbing *could* be factored out and diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md index 0afd815d5..63b83854f 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -20,7 +20,7 @@ Items that depend on the architecture decision deferred to Nagatha (1d) are mark **[depends on 1d]**. > **Post-outage reframe (generic button).** First-class "Cancel" was removed in favour of a -> generic button facility: a caller attaches a button with `with_button(id, label)`, the click +> generic button facility: a caller attaches a button with `with_action(label, id)`, the click > enqueues the caller's id, the owning screen drains it via `take_actions` and runs its own logic > (including cancellation), and Esc does **not** dismiss. The Cancel-specific cases below > (TC-OVL-024/025/026/027/042/043/044) are **reframed in place** to the generic-button model — @@ -42,15 +42,15 @@ illustrative; the architecture phase (1d) may adjust them. | Assumed name | Purpose | |---|---| -| `ProgressOverlay::show_global(ctx, description, config)` | Raises the overlay; returns `OverlayHandle` | +| `ProgressOverlay::set_global(ctx, description, config)` | Raises the overlay; returns `OverlayHandle` | | `ProgressOverlay::has_global(ctx)` | Returns `true` when an overlay is active | -| `ProgressOverlay::show_global_spinner_only(ctx)` | Convenience: spinner-only, no text | +| `ProgressOverlay::set_global_spinner_only(ctx)` | Convenience: spinner-only, no text | | `ProgressOverlay::render_global(ctx)` | Render call from `AppState::update()` | | `ProgressOverlay::take_actions(ctx)` | Drains the action-id queue (FIFO) | | `OverlayHandle::set_description(text)` | Updates description; returns `Option<&Self>` | | `OverlayHandle::set_step(current, total)` | Updates counter; returns `Option<&Self>` | | `OverlayHandle::clear_step()` | Removes counter; returns `Option<&Self>` | -| `OverlayConfig::with_button(id, label)` / `OverlayHandle::with_button(id, label)` | Adds a generic button (reframed post-outage: no built-in Cancel); the handle form returns `Option<&Self>` | +| `OverlayConfig::with_action(label, id)` / `OverlayHandle::with_action(label, id)` | Adds a generic button (reframed post-outage: no built-in Cancel); the handle form returns `Option<&Self>` | | `OverlayHandle::clear()` | Dismisses this handle's overlay entry | | `OverlayHandle::is_active()` | Returns `true` if still on the overlay stack | @@ -92,7 +92,7 @@ illustrative; the architecture phase (1d) may adjust them. **Preconditions:** Fresh context; no overlay active. **Steps:** -1. Inside `build_ui`, call `ProgressOverlay::show_global(ctx, "Registering your identity.", config_default())`. +1. Inside `build_ui`, call `ProgressOverlay::set_global(ctx, "Registering your identity.", config_default())`. 2. Also call `ProgressOverlay::render_global(ctx)`. 3. Call `harness.run()`. @@ -112,7 +112,7 @@ illustrative; the architecture phase (1d) may adjust them. **Preconditions:** Fresh context. **Steps:** -1. Call `let handle = ProgressOverlay::show_global(&ctx, "Loading.", config_default())`. +1. Call `let handle = ProgressOverlay::set_global(&ctx, "Loading.", config_default())`. 2. Assert `handle.is_active()` returns `true`. 3. Call `handle.set_description("Updated text.")` and assert it returns `Some(&handle)`. 4. Assert `ProgressOverlay::has_global(&ctx)` returns `true`. @@ -127,7 +127,7 @@ All assertions pass. The handle is non-null and addresses a live overlay entry. **Traceability:** FR-1 (AC-1.3), NFR-1 **Invariant to verify during code review:** -`ProgressOverlay::show_global()` must not acquire any async lock, call `.await`, or issue a +`ProgressOverlay::set_global()` must not acquire any async lock, call `.await`, or issue a `std::thread::sleep`. It must only write to `egui::ctx.data` (a synchronous, lock-guarded `TypeMap`). The implementation must be callable safely from `Screen::ui()` or from the app loop without any risk of yielding. @@ -193,7 +193,7 @@ loop without any risk of yielding. 2. Call `handle.clear()` to dismiss. 3. Call `handle.set_description("After clear")`. 4. Call `handle.set_step(1, 3)`. -5. Call `handle.with_button("overlay.action", "Continue")`. +5. Call `handle.with_action("Continue", "overlay.action")`. **Expected outcome:** - All handle method calls return `None`. @@ -276,7 +276,7 @@ overlay raised at dispatch time. **Preconditions:** Overlay raised in each of the three configurations: spinner-only, spinner+counter, spinner+counter+description+button. -**Steps:** For each configuration, call `show_global`, run one frame, query the rendered tree. +**Steps:** For each configuration, call `set_global`, run one frame, query the rendered tree. **Expected outcome:** An `egui::Spinner` widget (or widget with the spinner accessibility label, per implementation) @@ -476,7 +476,7 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash **Type:** kittest **Traceability:** FR-6 (AC-6.3), FR-5 (AC-5.4) -**Preconditions:** Overlay raised via `show_global_spinner_only(ctx)`. +**Preconditions:** Overlay raised via `set_global_spinner_only(ctx)`. **Steps:** 1. Raise spinner-only overlay. @@ -502,7 +502,7 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash **Preconditions:** Overlay raised with no buttons in config. **Steps:** -1. Show overlay with no `with_button` calls. +1. Show overlay with no `with_action` calls. 2. Run one frame. **Expected outcome:** @@ -517,7 +517,7 @@ Description is `"Waiting for the funding proof. This operation contacts the Dash **Type:** kittest **Traceability:** FR-7 (AC-7.2, AC-7.3) -**Preconditions:** Overlay raised with `with_button("overlay.cancel", "Cancel")`. There is no +**Preconditions:** Overlay raised with `with_action("Cancel", "overlay.cancel")`. There is no built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cancel"` a caller-chosen id. **Steps:** @@ -539,7 +539,7 @@ built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cance **Type:** kittest **Traceability:** FR-7 (AC-7.2) -**Preconditions:** Overlay raised with `with_button("overlay.run_in_bg", "Run in background")`. +**Preconditions:** Overlay raised with `with_action("Run in background", "overlay.run_in_bg")`. **Steps:** 1. Show overlay with generic action button labelled `"Run in background"`. @@ -560,7 +560,7 @@ built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cance **Preconditions:** Overlay raised with two generic buttons (no built-in Cancel). **Steps:** -1. Show overlay with `with_button("cancel", "Cancel")` and `with_button("secondary", "Secondary")`. +1. Show overlay with `with_action("Cancel", "cancel")` and `with_action("Secondary", "secondary")`. 2. Click `"Cancel"`; run one frame. 3. Click `"Secondary"`; run one frame. 4. Call `ProgressOverlay::take_actions(ctx)` once. @@ -575,8 +575,8 @@ built-in Cancel — `"Cancel"` is just a caller-chosen label and `"overlay.cance **Type:** kittest (widget-position assertion) + design-review **Traceability:** FR-7 (AC-7.4) -**Preconditions:** Overlay with `with_button("first", "First action")` and -`with_button("second", "Second action")`. There is no Cancel-specific placement — buttons render +**Preconditions:** Overlay with `with_action("First action", "first")` and +`with_action("Second action", "second")`. There is no Cancel-specific placement — buttons render left-to-right in insertion order. **Steps:** @@ -773,8 +773,8 @@ the critical check is that the banner is behind the input-blocking layer). **Preconditions:** Two overlay requests pushed. **Steps:** -1. Call `show_global(ctx, "Operation A.", cfg_a)` — capture `handle_a`. -2. Call `show_global(ctx, "Operation B.", cfg_b)` — capture `handle_b`. +1. Call `set_global(ctx, "Operation A.", cfg_a)` — capture `handle_a`. +2. Call `set_global(ctx, "Operation B.", cfg_b)` — capture `handle_b`. 3. Run one frame. **Expected outcome (stack model):** @@ -843,7 +843,7 @@ with id `"cancel_b"` (both labelled `"Cancel"`, a caller-chosen label). **Traceability:** FR-10 (AC-10.4) **Invariant:** -When a second `show_global` call is made while an overlay is already active, a single log +When a second `set_global` call is made while an overlay is already active, a single log entry noting the concurrent request is emitted. Across subsequent frames where both remain on the stack, no further log entry is emitted for the concurrency (log-once, guarded by flag — mirrors `BannerState.logged`). @@ -886,7 +886,7 @@ events are not forwarded to `pass_events_to_game_while_any_popup_is_open = false **Type:** kittest **Traceability:** NFR-3 (AC-3b) -**Preconditions:** Overlay raised with a generic button (`with_button("overlay.cancel", "Cancel")`). +**Preconditions:** Overlay raised with a generic button (`with_action("Cancel", "overlay.cancel")`). There is no built-in Cancel, so Esc has nothing to trigger. **Steps:** @@ -953,7 +953,7 @@ keyboard — Enter must not trigger it. See AC-3c. **Traceability:** NFR-5 **Invariant:** -1. On `show_global`: exactly one log entry at `debug` or `info` level noting overlay raised. +1. On `set_global`: exactly one log entry at `debug` or `info` level noting overlay raised. On subsequent frames with no state change, no further log entry for the overlay. 2. On `set_description` / `set_step` (content change): exactly one log entry. 3. On `handle.clear()`: exactly one log entry noting dismissal. @@ -1050,7 +1050,7 @@ present but not blocking the prompt. **Invariant:** The entire call path of `ProgressOverlay::render_global(ctx)` and -`ProgressOverlay::show_global(ctx, ...)` must be synchronous and non-blocking: +`ProgressOverlay::set_global(ctx, ...)` must be synchronous and non-blocking: - No `.await`, no `async fn`, no `tokio::block_on`, no `std::thread::sleep` in the render or show path. - No `Mutex::lock().await` or `RwLock::write().await`. diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index 41dde1a6c..f8be7834e 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -15,8 +15,8 @@ Cancel-specific decisions below. Where this document and the redesign disagree, 1. **No first-class Cancel — a generic button facility instead.** The overlay knows nothing about cancellation. `with_cancel`, `OVERLAY_CANCEL_ACTION_ID`, and `CANCEL_LABEL` are **removed**. A - caller attaches a generic button via `OverlayConfig::with_button(id, label)` / - `OverlayHandle::with_button(id, label)`, choosing its own opaque action id and label. Clicking + caller attaches a generic button via `OverlayConfig::with_action(label, id)` / + `OverlayHandle::with_action(label, id)`, choosing its own opaque action id and label. Clicking enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants — including its own cancellation. Esc/Tab/Enter are swallowed (a hard block is never keyboard- dismissable); there is no Esc→Cancel routing. This **supersedes D-5** (the shipped-but-unwired @@ -150,8 +150,8 @@ behavior: after simulated 30 s, `Elapsed: {seconds}s` and the reassurance label token; the operation runs inside `block_on` on a blocking thread. **Decision:** -- The overlay **ships a generic button/action-id API** (`OverlayConfig::with_button(id, label)`, - `OverlayHandle::with_button(id, label)`, `take_actions`) — it is UI-only, mirrors the banner, +- The overlay **ships a generic button/action-id API** (`OverlayConfig::with_action(label, id)`, + `OverlayHandle::with_action(label, id)`, `take_actions`) — it is UI-only, mirrors the banner, and is fully unit/kittest-testable (TC-OVL-024/025/026 verify the *enqueue* path). There is no `with_cancel`/`OVERLAY_CANCEL_ACTION_ID`; "Cancel" is merely one possible caller-chosen label. - **The architectural default for every production caller is a button-less block.** No @@ -191,7 +191,7 @@ top stays untestable until T7; note it as such. > **Superseded — see the code and the addendum.** The signature block below is the *original* > Cancel-era plan, kept for history. The shipped surface differs: there is **no** `with_cancel`, > `with_action`, `OVERLAY_CANCEL_ACTION_ID`, or `CANCEL_LABEL`, and `is_primary` is not a public -> field. The real builders are `with_button(id, label)` and `with_secondary_button(id, label)` +> field. The real builders are `with_action(label, id)` and `with_secondary_action(label, id)` > (on `OverlayConfig`, `OverlayHandle`, and the instance form), backed by a private > `ButtonStyle { Primary, Secondary }`. Clicks are delivered **keyed** to the owner via > `OverlayHandle::take_actions()`; the static drain is `sweep_orphan_actions()` (see addendum §2). @@ -249,7 +249,7 @@ struct OverlayButton { is_primary: bool, } -/// Builder/config for `show_global`. `OverlayConfig::default()` == the test +/// Builder/config for `set_global`. `OverlayConfig::default()` == the test /// spec's `config_default()` (spinner only, no counter, no buttons, elapsed off). #[derive(Clone, Default)] pub struct OverlayConfig { /* description, step, show_elapsed, buttons */ } @@ -285,10 +285,10 @@ pub struct ProgressOverlay; impl ProgressOverlay { /// Push a request; returns its handle. Non-blocking; only writes `ctx.data`. - pub fn show_global(ctx: &egui::Context, + pub fn set_global(ctx: &egui::Context, description: impl std::fmt::Display, config: OverlayConfig) -> OverlayHandle; - pub fn show_global_spinner_only(ctx: &egui::Context) -> OverlayHandle; + pub fn set_global_spinner_only(ctx: &egui::Context) -> OverlayHandle; pub fn has_global(ctx: &egui::Context) -> bool; // cheap one-slot read /// Called once per frame from `AppState::update` (§4). Renders the topmost /// entry; no-op early-out when the stack is empty (NFR-6). @@ -308,7 +308,7 @@ pub trait OptionOverlayExt { impl OptionOverlayExt for Option { /* … */ } ``` -**Stack/key semantics (D-3):** `show_global` allocates a key via `OVERLAY_KEY_COUNTER`, pushes +**Stack/key semantics (D-3):** `set_global` allocates a key via `OVERLAY_KEY_COUNTER`, pushes an `OverlayState`, and (when the stack was already non-empty) logs the concurrency exactly once. Handle methods `find` by key and return `None` on a missing key (stale handle → no-op, never panic; TC-OVL-007/009). `clear(self)` `retain`s out its own key (TC-OVL-037/038). @@ -467,8 +467,8 @@ component; T6 is tests; T7 is the deferred backend enabler. ### T1 — State, `ctx.data` plumbing, handle + stack (no rendering) (~250 LOC) Define `OverlayState`, `OverlayButton`, `OverlayConfig` (+ builders), `OverlayHandle`, `OVERLAY_KEY_COUNTER`, `OVERLAY_CANCEL_ACTION_ID`, `STUCK_OVERLAY_THRESHOLD`. Implement -`get/set_overlay_state`, `get/set/push_overlay_action`, `take_actions`; `show_global`, -`show_global_spinner_only`, `has_global`, `clear_all_global`; all handle methods with stack +`get/set_overlay_state`, `get/set/push_overlay_action`, `take_actions`; `set_global`, +`set_global_spinner_only`, `has_global`, `clear_all_global`; all handle methods with stack semantics (push on show, `retain` own key on clear, topmost lookup), log-once flags, and `step_is_renderable`. Inline unit tests for stack push/dismiss, FIFO queue, `step_is_renderable`. **Satisfies (ctx.data-level):** TC-OVL-003, 007, 009, 023(empty queue), 036, 037, 038, 040, 045, @@ -528,8 +528,8 @@ TODO so the gap is tracked, not lost. the overlay-active branch; verify global shortcuts (and the existing `handle_banner_esc`, `app.rs:1089`) still behave when no overlay is up. The banner Esc handler and overlay Esc handler must not both fire — overlay consumes Esc first (it renders earlier in the frame). -3. **No backend cancellation (D-5).** Enforce by review: no production `show_global` call may - attach a button (`with_button`) to a `BackendTask`-backed operation until T7. Consider a +3. **No backend cancellation (D-5).** Enforce by review: no production `set_global` call may + attach a button (`with_action`) to a `BackendTask`-backed operation until T7. Consider a clippy-grep gate in CI. 4. **Repaint discipline.** `request_repaint_after(1s)` only when elapsed/threshold is live; the `Spinner` already self-repaints. Do not unconditionally wake an idle UI. @@ -549,7 +549,7 @@ TODO so the gap is tracked, not lost. | D-2 | Focused copy of `ctx.data` plumbing; no shared base module | Confirmed | | D-3 | Concurrent model = **stack** keyed by handle (Group K stands) | Confirmed | | D-4 | Stuck threshold = **30 s** soft reveal; **+120 s no-progress watchdog** (addendum §1 A-1) | Superseded by addendum §1 | -| D-5 | No built-in Cancel; generic `with_button`/`with_secondary_button`; clicks keyed to the owner via `take_actions`, app sweeps orphans (addendum §2) | Superseded (post-outage + addendum §2) | +| D-5 | No built-in Cancel; generic `with_action`/`with_secondary_action`; clicks keyed to the owner via `take_actions`, app sweeps orphans (addendum §2) | Superseded (post-outage + addendum §2) | --- diff --git a/src/app.rs b/src/app.rs index 4896f9974..c8566fe0a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,6 +9,7 @@ use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::context::connection_status::{ ConnectionStatus, OverallConnectionState, SPV_SYNC_PHASE_COUNT, spv_phase_step, + spv_progress_token, }; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; @@ -1153,7 +1154,7 @@ impl AppState { /// is stranded). /// /// Raises the overlay at most once per episode (then updates content in place - /// via the handle), so it never `show_global`s every frame. + /// via the handle), so it never `set_global`s every frame. fn update_spv_overlay(&mut self, ctx: &egui::Context, app_context: &Arc) { let cs = app_context.connection_status(); let state = cs.overall_state(); @@ -1161,7 +1162,13 @@ impl AppState { SpvBlockStep::Block => { // F-SPV-B: plain, jargon-free copy — the determinate granularity is // the "Step N of 5" counter, NOT raw phase names / heights / %. - let step = cs.spv_sync_progress().as_ref().and_then(spv_phase_step); + let progress = cs.spv_sync_progress(); + let step = progress.as_ref().and_then(spv_phase_step); + // A-1 (Item B): the hidden liveness token tracks the advancing + // height so a slow-but-advancing phase (whose "Step N of 5" stays + // constant for minutes) never trips the no-progress watchdog. It is + // never rendered — no height/number leaks into the shown copy. + let token = progress.as_ref().and_then(spv_progress_token); let description = if step.is_some() { SPV_SYNCING_DESCRIPTION } else { @@ -1174,13 +1181,16 @@ impl AppState { // strand keyboard-only users) — pending product decision. let mut config = OverlayConfig::new() .with_description(description) - .with_secondary_button( - SPV_CONTINUE_BACKGROUND_ACTION, + .with_secondary_action( "Continue in the background", + SPV_CONTINUE_BACKGROUND_ACTION, ); if let Some(n) = step { config = config.with_step(n, SPV_SYNC_PHASE_COUNT); } + if let Some(t) = token { + config = config.with_progress_token(t); + } self.spv_overlay.raise(ctx, "", config); } else if let Some(handle) = &self.spv_overlay { handle.set_description(description); @@ -1192,6 +1202,9 @@ impl AppState { handle.clear_step(); } } + if let Some(t) = token { + handle.set_progress_token(t); + } } } SpvBlockStep::Disarm => { @@ -1787,6 +1800,15 @@ impl App for AppState { } } + // Drive the SPV-sync block BEFORE claiming input and running the screen, so + // a freshly-armed episode RAISES the overlay in time for THIS frame's input + // claim + global render. Otherwise (raising after the claim + screen) the + // frame right after Connect/arming is fully interactive and the block only + // takes effect a frame later — the one-frame interactive gap. The connection + // banner still reads the block state afterwards (it suppresses its redundant + // Connecting/Syncing copy while the block is up). + self.update_spv_overlay(ctx, &active_context); + // Total input block at frame start: while a blocking overlay is up, claim // all keyboard + text input BEFORE the panels run (QA-001) — unless a // secret prompt is active above the overlay (it needs the keyboard). @@ -1818,9 +1840,10 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); - // Drive the SPV-sync block first so the connection banner can suppress its - // redundant Connecting/Syncing text while the overlay is up. - self.update_spv_overlay(ctx, &active_context); + // The SPV-sync block was already driven at frame start (above), before the + // input claim + screen, to close the one-frame interactive gap. It still + // runs before the connection banner, which suppresses its redundant + // Connecting/Syncing text while the overlay is up. self.update_connection_banner(ctx, &active_context); self.dispatch_cold_start_migration(); self.update_migration_banner(ctx, &active_context); diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 68f6972bf..1648be6dc 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -646,29 +646,46 @@ pub fn spv_phase_summary(progress: &SpvSyncProgress) -> String { /// count [`spv_phase_step`] actually walks. pub const SPV_SYNC_PHASE_COUNT: u32 = 5; +/// The currently-active SPV sync phase as `(1-based step, current height)`, or +/// `None` when no phase is actively syncing yet. Mirrors the pipeline order of +/// [`spv_phase_summary`]; the height is the phase's processed tip (Blocks reports +/// `last_processed`, every other phase its `current_height`). Shared by +/// [`spv_phase_step`] (the shown counter) and [`spv_progress_token`] (the hidden +/// watchdog liveness signal) so the two can never disagree on which phase is live. +fn active_spv_phase(progress: &SpvSyncProgress) -> Option<(u32, u32)> { + let is_syncing = |state: SyncState| state == SyncState::Syncing; + if let Ok(p) = progress.headers() + && is_syncing(p.state()) + { + Some((1, p.current_height())) + } else if let Ok(p) = progress.masternodes() + && is_syncing(p.state()) + { + Some((2, p.current_height())) + } else if let Ok(p) = progress.filter_headers() + && is_syncing(p.state()) + { + Some((3, p.current_height())) + } else if let Ok(p) = progress.filters() + && is_syncing(p.state()) + { + Some((4, p.current_height())) + } else if let Ok(p) = progress.blocks() + && is_syncing(p.state()) + { + Some((SPV_SYNC_PHASE_COUNT, p.last_processed())) + } else { + None + } +} + /// Map the currently-active SPV sync phase to a 1-based step number for the /// blocking overlay's "Step N of {total}" counter — Headers=1, Masternodes=2, /// Filter Headers=3, Filters=4, Blocks=[`SPV_SYNC_PHASE_COUNT`] — or `None` when /// no phase is actively syncing yet. Mirrors the pipeline order of /// [`spv_phase_summary`]. pub fn spv_phase_step(progress: &SpvSyncProgress) -> Option { - let is_syncing = |state: SyncState| state == SyncState::Syncing; - let step = if progress.headers().is_ok_and(|p| is_syncing(p.state())) { - 1 - } else if progress.masternodes().is_ok_and(|p| is_syncing(p.state())) { - 2 - } else if progress - .filter_headers() - .is_ok_and(|p| is_syncing(p.state())) - { - 3 - } else if progress.filters().is_ok_and(|p| is_syncing(p.state())) { - 4 - } else if progress.blocks().is_ok_and(|p| is_syncing(p.state())) { - SPV_SYNC_PHASE_COUNT - } else { - return None; - }; + let step = active_spv_phase(progress)?.0; debug_assert!( step <= SPV_SYNC_PHASE_COUNT, "SPV phase step {step} exceeds SPV_SYNC_PHASE_COUNT {SPV_SYNC_PHASE_COUNT} — bump the constant" @@ -676,6 +693,18 @@ pub fn spv_phase_step(progress: &SpvSyncProgress) -> Option { Some(step) } +/// A **hidden, monotonic** liveness token for the blocking overlay's no-progress +/// watchdog (A-1). It strictly increases as SPV sync advances — within a phase the +/// active phase's height climbs (low 32 bits), and across phases the 1-based step +/// climbs (high 32 bits) — so a slow-but-advancing phase (e.g. Headers on a slow +/// link) keeps resetting the watchdog even though the shown "Step N of 5" copy is +/// unchanged. NEVER rendered; no height or number leaks into user-facing copy. +/// `None` when no phase is actively syncing yet. +pub fn spv_progress_token(progress: &SpvSyncProgress) -> Option { + let (step, height) = active_spv_phase(progress)?; + Some(((step as u64) << 32) | height as u64) +} + fn pct(current: u32, target: u32) -> u32 { if target == 0 { 0 @@ -737,6 +766,36 @@ mod tests { assert_eq!(spv_phase_step(&SpvSyncProgress::default()), None); } + /// Item B — the hidden watchdog liveness token advances as the active phase's + /// height climbs (so a slow-but-advancing phase resets the no-progress + /// watchdog), is monotonic across the step transition, and is `None` when idle. + #[test] + fn spv_progress_token_advances_with_height_and_is_monotonic() { + // Idle progress → no token. + assert_eq!(spv_progress_token(&SpvSyncProgress::default()), None); + + // Headers at 5000: a token exists. + let progress = syncing_progress(); + let t1 = spv_progress_token(&progress).expect("syncing → token"); + + // Same phase, higher tip (7000) → strictly larger token. + let mut headers = BlockHeadersProgress::default(); + headers.set_state(SyncState::Syncing); + headers.update_target_height(10_000); + headers.update_tip_height(7_000); + let mut advanced = SpvSyncProgress::default(); + advanced.update_headers(headers); + let t2 = spv_progress_token(&advanced).expect("syncing → token"); + assert!( + t2 > t1, + "an advancing height yields a strictly larger token" + ); + + // The step lives in the high 32 bits, so a later phase always out-ranks an + // earlier one regardless of height — the token is monotonic across phases. + assert_eq!(t1 >> 32, 1, "headers maps to step 1 in the high bits"); + } + #[test] fn spv_status_snapshot_reflects_live_state() { let status = ConnectionStatus::new(); diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 9c0422498..5609ffaa5 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -34,7 +34,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | Description | |-----------|------|-------------| | `MessageBanner` | `message_banner.rs` | Global error/warning/success/info banners. `set_global()`, `with_details()`, auto-dismiss. Extensions: `OptionBannerExt`, `OptionBannerShowExt`, `ResultBannerExt` | -| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_button(id, label)`), 120s watchdog. Global path: `show_global()` / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt`, `ProgressOverlayResponse` | +| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_action(label, action_id)`, mirrors `MessageBanner::with_action`), 120s watchdog. Global path: `set_global()` (raise, mirrors `MessageBanner::set_global`) / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt` (`raise` — the banner's `replace`, renamed to dodge inherent `Option::replace`), `ProgressOverlayResponse` | ## Styled Components (`styled.rs`) diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index eb8e09cae..ba989cbdf 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -14,8 +14,8 @@ //! ## Buttons are a generic facility (no built-in Cancel) //! //! The overlay has **no** Cancel concept. A caller attaches a generic button -//! with [`OverlayConfig::with_button`] / [`OverlayHandle::with_button`] (or the -//! `*_secondary_button` variants), picking its own opaque action id and label. +//! with [`OverlayConfig::with_action`] / [`OverlayHandle::with_action`] (or the +//! `*_secondary_action` variants), picking its own opaque action id and label. //! Clicking the button enqueues that action id, keyed by the owning entry; the //! overlay does **not** auto-lower. The owning screen drains **its own** ids via //! [`OverlayHandle::take_actions`] (FIFO) at the top of its `ui()` and decides @@ -28,7 +28,7 @@ //! Like `MessageBanner`, this type has both an instance [`Component`] path and a //! global path, sharing one layout helper ([`render_card`]): //! -//! - **Global** — state lives in egui `ctx.data`; [`ProgressOverlay::show_global`] +//! - **Global** — state lives in egui `ctx.data`; [`ProgressOverlay::set_global`] //! raises it, [`ProgressOverlay::render_global`] paints the full-window dim + //! input sink + centered card once per frame from `AppState::update`. This is //! the app-level blocking path the application depends on. @@ -144,9 +144,18 @@ struct OverlayState { logged: bool, /// Last content logged, so a description/step update logs exactly once. logged_content: Option, - /// Last time the content (description/step) actually changed. The no-progress - /// watchdog (A-1) measures from here, so a legitimately advancing multi-step - /// flow never trips it while a genuinely wedged single step does. + /// Hidden, monotonic liveness token (A-1). Never rendered. An owner that drives + /// a long phase whose shown `(description, step)` is constant (e.g. SPV headers) + /// advances this from the underlying progress (a climbing height) so the + /// watchdog can tell a slow-but-advancing phase from a genuine stall. + progress_token: Option, + /// The `progress_token` value at the last watchdog reset, for change detection. + last_progress_token: Option, + /// Last time real progress was seen — either the shown `(description, step)` + /// changed OR the hidden `progress_token` advanced. The no-progress watchdog + /// (A-1) measures from here, so a legitimately advancing flow (multi-step or a + /// single slow-but-advancing phase) never trips it while a genuinely wedged + /// operation does. last_progress_at: Instant, /// Set once the no-progress watchdog has fired its one-shot dev-error (A-1), /// so the error logs exactly once, never per frame (NFR-5). @@ -167,6 +176,8 @@ impl OverlayState { created_at: now, logged: false, logged_content: None, + progress_token: config.progress_token, + last_progress_token: config.progress_token, last_progress_at: now, watchdog_logged: false, focus_requested: false, @@ -174,7 +185,7 @@ impl OverlayState { } } -/// Builder/config for [`ProgressOverlay::show_global`]. `OverlayConfig::default()` +/// Builder/config for [`ProgressOverlay::set_global`]. `OverlayConfig::default()` /// is a spinner-only block: no counter, no buttons, elapsed off. #[derive(Clone, Default)] pub struct OverlayConfig { @@ -182,6 +193,10 @@ pub struct OverlayConfig { step: Option<(u32, u32)>, show_elapsed: bool, buttons: Vec, + /// Hidden liveness token (A-1). Never rendered; see [`with_progress_token`]. + /// + /// [`with_progress_token`]: Self::with_progress_token + progress_token: Option, } impl OverlayConfig { @@ -189,7 +204,7 @@ impl OverlayConfig { Self::default() } - /// Set the description. The `description` argument of `show_global` wins when + /// Set the description. The `description` argument of `set_global` wins when /// this is unset, so most callers pass the text there instead. pub fn with_description(mut self, text: impl fmt::Display) -> Self { let text = text.to_string(); @@ -208,37 +223,49 @@ impl OverlayConfig { self } - /// Add a **primary** action button (accent fill, hugs the right edge). The - /// caller owns the opaque `id` enqueued on click and the `label` shown on the - /// button; clicking does not lower the overlay — the owning screen drains the + /// Seed the **hidden** liveness token (A-1). NOT rendered to the user — it only + /// feeds the no-progress watchdog: a change in the token between frames counts + /// as progress and resets the watchdog clock, so a slow-but-still-advancing + /// operation (e.g. SPV headers on a slow link, where the shown "Step N of 5" + /// stays constant for minutes) never trips the false-stall escalation. Most + /// callers update it each frame via [`OverlayHandle::set_progress_token`]. + pub fn with_progress_token(mut self, token: u64) -> Self { + self.progress_token = Some(token); + self + } + + /// Add a **primary** action button (accent fill, hugs the right edge). Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// the displayed `label` comes first, the opaque `action_id` enqueued on click + /// second. Clicking does not lower the overlay — the owning screen drains the /// id and decides what to do. /// /// Buttons render right-to-left in the order added: primaries hug the right - /// edge, secondaries sit to their left. SEC-006: `id` and `label` are + /// edge, secondaries sit to their left. SEC-006: `label` and `action_id` are /// user-visible and logged — never pass secrets or PII. - pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { + pub fn with_action(mut self, label: impl fmt::Display, action_id: impl fmt::Display) -> Self { self.buttons - .push(OverlayButton::new(id, label, ButtonStyle::Primary)); + .push(OverlayButton::new(action_id, label, ButtonStyle::Primary)); self } /// Add a **secondary** action button (muted fill, sits left of the primary). - /// Same generic semantics as [`with_button`](Self::with_button) — only the - /// styling and placement differ; there is no built-in Cancel. SEC-006: `id` - /// and `label` are user-visible and logged — never pass secrets or PII. - pub fn with_secondary_button( + /// Same generic semantics as [`with_action`](Self::with_action) — only the + /// styling and placement differ; there is no built-in Cancel. SEC-006: `label` + /// and `action_id` are user-visible and logged — never pass secrets or PII. + pub fn with_secondary_action( mut self, - id: impl fmt::Display, label: impl fmt::Display, + action_id: impl fmt::Display, ) -> Self { self.buttons - .push(OverlayButton::new(id, label, ButtonStyle::Secondary)); + .push(OverlayButton::new(action_id, label, ButtonStyle::Secondary)); self } } /// Lifecycle handle for a raised overlay, returned by -/// [`ProgressOverlay::show_global`]. Identifies its entry by an internal key, so +/// [`ProgressOverlay::set_global`]. Identifies its entry by an internal key, so /// content can be updated without losing the reference. Methods are no-ops /// returning `None` once the entry is gone. /// @@ -290,27 +317,42 @@ impl OverlayHandle { self.mutate(|s| s.step = None) } - /// Attach a **primary** action button (accent fill, right edge) — opaque `id` - /// enqueued on click + `label` shown verbatim. Buttons render right-to-left in - /// the order added. SEC-006: `id`/`label` are user-visible and logged — never - /// pass secrets or PII. Returns `None` if the entry is gone. - pub fn with_button(&self, id: impl fmt::Display, label: impl fmt::Display) -> Option<&Self> { - let button = OverlayButton::new(id, label, ButtonStyle::Primary); + /// Attach a **primary** action button (accent fill, right edge). Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// `label` shown verbatim first, opaque `action_id` enqueued on click second. + /// Buttons render right-to-left in the order added. SEC-006: `label`/`action_id` + /// are user-visible and logged — never pass secrets or PII. Returns `None` if + /// the entry is gone. + pub fn with_action( + &self, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Option<&Self> { + let button = OverlayButton::new(action_id, label, ButtonStyle::Primary); self.mutate(|s| s.buttons.push(button)) } /// Attach a **secondary** action button (muted fill, left of the primary). - /// Same generic semantics as [`with_button`](Self::with_button). SEC-006: - /// `id`/`label` are user-visible and logged. Returns `None` if the entry is gone. - pub fn with_secondary_button( + /// Same generic semantics as [`with_action`](Self::with_action). SEC-006: + /// `label`/`action_id` are user-visible and logged. Returns `None` if the entry + /// is gone. + pub fn with_secondary_action( &self, - id: impl fmt::Display, label: impl fmt::Display, + action_id: impl fmt::Display, ) -> Option<&Self> { - let button = OverlayButton::new(id, label, ButtonStyle::Secondary); + let button = OverlayButton::new(action_id, label, ButtonStyle::Secondary); self.mutate(|s| s.buttons.push(button)) } + /// Update the **hidden** liveness token in place (A-1). NOT rendered — it only + /// feeds the no-progress watchdog so an advancing underlying operation (e.g. an + /// SPV phase whose height climbs while the shown "Step N of 5" stays constant) + /// resets the watchdog clock. Returns `None` if the entry is gone. + pub fn set_progress_token(&self, token: u64) -> Option<&Self> { + self.mutate(|s| s.progress_token = Some(token)) + } + /// Drain (FIFO) and remove the action ids enqueued by **this handle's** /// button clicks, leaving other overlay entries' actions untouched (A-3). /// @@ -421,7 +463,7 @@ impl ComponentResponse for ProgressOverlayResponse { /// The blocking progress overlay. /// /// Two paths share the [`render_card`] layout helper (mirrors `MessageBanner`): -/// the global `ctx.data` path ([`show_global`](Self::show_global) / +/// the global `ctx.data` path ([`set_global`](Self::set_global) / /// [`render_global`](Self::render_global)), driven once per frame from /// `AppState::update`, and the instance [`Component`] path configured by the /// builder methods and rendered by [`Component::show`]. @@ -476,28 +518,30 @@ impl ProgressOverlay { self } - /// Add a **primary** action button. The caller owns the opaque `id` surfaced - /// through [`ProgressOverlayResponse`] on click and the `label` shown on it. - pub fn with_button(mut self, id: impl fmt::Display, label: impl fmt::Display) -> Self { + /// Add a **primary** action button. Mirrors + /// [`MessageBanner::with_action`](super::message_banner::MessageBanner::with_action): + /// the `label` shown on the button comes first, the opaque `action_id` surfaced + /// through [`ProgressOverlayResponse`] on click second. + pub fn with_action(mut self, label: impl fmt::Display, action_id: impl fmt::Display) -> Self { if let Some(state) = &mut self.state { state .buttons - .push(OverlayButton::new(id, label, ButtonStyle::Primary)); + .push(OverlayButton::new(action_id, label, ButtonStyle::Primary)); } self } /// Add a **secondary** action button (left of the primary). Same generic - /// semantics as [`with_button`](Self::with_button). - pub fn with_secondary_button( + /// semantics as [`with_action`](Self::with_action). + pub fn with_secondary_action( mut self, - id: impl fmt::Display, label: impl fmt::Display, + action_id: impl fmt::Display, ) -> Self { if let Some(state) = &mut self.state { state .buttons - .push(OverlayButton::new(id, label, ButtonStyle::Secondary)); + .push(OverlayButton::new(action_id, label, ButtonStyle::Secondary)); } self } @@ -524,7 +568,7 @@ impl ProgressOverlay { /// /// SEC-006: the `description` (and any button `label`/`id`) is user-visible /// and written to logs on show — never pass secrets, passphrases, or PII. - pub fn show_global( + pub fn set_global( ctx: &egui::Context, description: impl fmt::Display, config: OverlayConfig, @@ -555,11 +599,11 @@ impl ProgressOverlay { /// Convenience: a spinner-only block with no text, counter, or buttons. /// /// As a button-less block it has no escape, so the SEC-001 lifecycle rule from - /// [`show_global`](Self::show_global) applies in full: drive it from a + /// [`set_global`](Self::set_global) applies in full: drive it from a /// frame-driven reconcile owner (e.g. `AppState::update_spv_overlay`) that /// lowers it when the work ends — a leaked handle has no automatic teardown. - pub fn show_global_spinner_only(ctx: &egui::Context) -> OverlayHandle { - Self::show_global(ctx, "", OverlayConfig::default()) + pub fn set_global_spinner_only(ctx: &egui::Context) -> OverlayHandle { + Self::set_global(ctx, "", OverlayConfig::default()) } /// Whether any overlay is active. Cheap one-slot read (NFR-6). @@ -845,22 +889,43 @@ fn step_is_renderable(current: u32, total: u32) -> bool { current >= 1 && total >= 1 && current <= total } -/// Log the overlay once on show and once per content change (NFR-5). +/// Log the overlay once on show and once per *visible* content change (NFR-5), +/// and reset the no-progress watchdog clock on any real progress — a change in the +/// shown `(description, step)` OR an advance of the hidden `progress_token` (A-1). +/// +/// The two signals are deliberately separated: the debug log fires only on a shown +/// content change (so a per-frame token advance never spams the log), while the +/// watchdog reset also honours the token (so a slow-but-advancing phase whose shown +/// copy is constant — e.g. SPV headers on a slow link — never trips a false stall). fn log_overlay_state(state: &mut OverlayState) { let content = (state.description.clone(), state.step); if !state.logged { state.logged = true; state.logged_content = Some(content); + state.last_progress_token = state.progress_token; debug!( description = ?state.description, step = ?state.step, "Blocking progress overlay shown" ); - } else if state.logged_content.as_ref() != Some(&content) { - state.logged_content = Some(content); - // Real progress: reset the no-progress watchdog clock (A-1) so a - // legitimately advancing flow never trips it. + return; + } + + let content_changed = state.logged_content.as_ref() != Some(&content); + let token_advanced = state.progress_token != state.last_progress_token; + + if content_changed || token_advanced { + // Real progress (shown copy changed, or the hidden token advanced): reset + // the no-progress watchdog clock (A-1) so a legitimately advancing flow + // never trips it. state.last_progress_at = Instant::now(); + state.last_progress_token = state.progress_token; + } + + if content_changed { + // Only a *shown* change is logged, exactly once (NFR-5) — a per-frame token + // advance is a hidden liveness signal, not a user-visible update. + state.logged_content = Some(content); debug!( description = ?state.description, step = ?state.step, @@ -1102,8 +1167,12 @@ pub trait OptionOverlayExt { /// Take the handle (leaving `None`) and dismiss its overlay entry. fn take_and_clear(&mut self); - /// Clear any existing overlay, raise a new one, and store the handle. Named - /// `raise` (not `replace`) to avoid shadowing the inherent `Option::replace`. + /// Clear any existing overlay, raise a new one, and store the handle. The + /// banner analogue is [`OptionBannerExt::replace`](super::message_banner::OptionBannerExt::replace), + /// but this stays named `raise`: an inherent `Option::replace(value)` already + /// exists and wins method resolution, so naming this `replace` would shadow it + /// and make every `slot.replace(ctx, desc, config)` call fail to compile + /// (arity mismatch against the inherent one-arg method). fn raise(&mut self, ctx: &egui::Context, description: impl fmt::Display, config: OverlayConfig); } @@ -1121,7 +1190,7 @@ impl OptionOverlayExt for Option { config: OverlayConfig, ) { self.take_and_clear(); - *self = Some(ProgressOverlay::show_global(ctx, description, config)); + *self = Some(ProgressOverlay::set_global(ctx, description, config)); } } @@ -1161,7 +1230,7 @@ mod tests { fn show_pushes_entry_and_has_global_reports_it() { let ctx = egui::Context::default(); assert!(!ProgressOverlay::has_global(&ctx)); - let handle = ProgressOverlay::show_global(&ctx, "Loading.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Loading.", OverlayConfig::default()); assert!(ProgressOverlay::has_global(&ctx)); assert!(handle.is_active()); assert!(handle.elapsed().is_some()); @@ -1170,7 +1239,7 @@ mod tests { #[test] fn config_with_description_wins_over_argument() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &ctx, "", OverlayConfig::new().with_description("From config."), @@ -1183,7 +1252,7 @@ mod tests { #[test] fn spinner_only_has_no_text() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global_spinner_only(&ctx); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); let stack = get_overlay_state(&ctx); let entry = stack.iter().find(|s| s.key == handle.key).unwrap(); assert!(entry.description.is_none()); @@ -1194,14 +1263,14 @@ mod tests { #[test] fn stale_handle_updates_are_none_and_do_not_panic() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global(&ctx, "Gone soon.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Gone soon.", OverlayConfig::default()); handle.clone().clear(); assert!(handle.set_description("After clear").is_none()); assert!(handle.set_step(1, 3).is_none()); assert!(handle.clear_step().is_none()); assert!( handle - .with_button("overlay.bg", "Run in background") + .with_action("Run in background", "overlay.bg") .is_none() ); assert!(!ProgressOverlay::has_global(&ctx)); @@ -1210,7 +1279,7 @@ mod tests { #[test] fn double_clear_is_a_noop() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global(&ctx, "Once.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Once.", OverlayConfig::default()); handle.clone().clear(); handle.clear(); assert!(!ProgressOverlay::has_global(&ctx)); @@ -1219,8 +1288,8 @@ mod tests { #[test] fn stack_renders_topmost_and_each_handle_clears_only_itself() { let ctx = egui::Context::default(); - let a = ProgressOverlay::show_global(&ctx, "Operation A.", OverlayConfig::default()); - let b = ProgressOverlay::show_global(&ctx, "Operation B.", OverlayConfig::default()); + let a = ProgressOverlay::set_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "Operation B.", OverlayConfig::default()); assert!(a.is_active()); assert!(b.is_active()); @@ -1248,7 +1317,7 @@ mod tests { #[test] fn handle_take_actions_drains_own_fifo_then_empties() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global_spinner_only(&ctx); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); assert!(handle.take_actions().is_empty()); push_overlay_action(&ctx, handle.key, "first"); @@ -1266,8 +1335,8 @@ mod tests { #[test] fn keyed_actions_isolate_owners() { let ctx = egui::Context::default(); - let a = ProgressOverlay::show_global(&ctx, "A.", OverlayConfig::default()); - let b = ProgressOverlay::show_global(&ctx, "B.", OverlayConfig::default()); + let a = ProgressOverlay::set_global(&ctx, "A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "B.", OverlayConfig::default()); push_overlay_action(&ctx, b.key, "b:action"); assert!(a.take_actions().is_empty(), "A must not see B's click"); @@ -1282,13 +1351,13 @@ mod tests { let ctx = egui::Context::default(); // Owner clears normally → its pending id is purged, sweeper finds nothing. - let cleared = ProgressOverlay::show_global_spinner_only(&ctx); + let cleared = ProgressOverlay::set_global_spinner_only(&ctx); push_overlay_action(&ctx, cleared.key, "cleared:id"); cleared.clear(); assert!(ProgressOverlay::sweep_orphan_actions(&ctx).is_empty()); // Owner dropped without draining → its id is orphaned and swept once. - let dropped = ProgressOverlay::show_global_spinner_only(&ctx); + let dropped = ProgressOverlay::set_global_spinner_only(&ctx); let dropped_key = dropped.key; push_overlay_action(&ctx, dropped_key, "dropped:id"); ProgressOverlay::clear_all_global(&ctx); // entry gone, id keyed to a dead owner @@ -1307,7 +1376,7 @@ mod tests { #[test] fn clear_all_global_clears_action_queue() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global_spinner_only(&ctx); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); push_overlay_action(&ctx, handle.key, "shielded:build:cancel"); ProgressOverlay::clear_all_global(&ctx); @@ -1336,7 +1405,7 @@ mod tests { #[test] fn claim_input_strips_text_and_nav_keys_when_block_active() { let ctx = egui::Context::default(); - ProgressOverlay::show_global_spinner_only(&ctx); + ProgressOverlay::set_global_spinner_only(&ctx); let leaked = std::cell::Cell::new(true); let raw = egui::RawInput { @@ -1401,7 +1470,7 @@ mod tests { #[test] fn render_logs_once_then_marks_logged() { let ctx = egui::Context::default(); - ProgressOverlay::show_global(&ctx, "Working.", OverlayConfig::default()); + ProgressOverlay::set_global(&ctx, "Working.", OverlayConfig::default()); render_once(&ctx); let stack = get_overlay_state(&ctx); let entry = stack.last().unwrap(); @@ -1420,7 +1489,7 @@ mod tests { fn elapsed_counts_up_monotonically() { let ctx = egui::Context::default(); let handle = - ProgressOverlay::show_global(&ctx, "Slow.", OverlayConfig::new().with_elapsed()); + ProgressOverlay::set_global(&ctx, "Slow.", OverlayConfig::new().with_elapsed()); let first = handle.elapsed().unwrap(); std::thread::sleep(Duration::from_millis(20)); let second = handle.elapsed().unwrap(); @@ -1474,7 +1543,7 @@ mod tests { let mut overlay = ProgressOverlay::new() .with_description("Instance overlay.") .with_step(2, 5) - .with_button("overlay.bg", "Run in background"); + .with_action("Run in background", "overlay.bg"); // No interaction has happened yet. assert!(overlay.current_value().is_none()); @@ -1539,12 +1608,60 @@ mod tests { ); } + /// A-1 (Item B) — an advancing hidden `progress_token` resets the no-progress + /// clock even when the shown `(description, step)` is unchanged (a slow-but- + /// advancing phase), but it must NOT emit a content-update log; an unchanged + /// token (a genuine stall) leaves the clock alone so the watchdog still trips. + #[test] + fn log_overlay_state_token_advance_resets_clock_without_content_change() { + let mut state = OverlayState::new( + 1, + Some("Syncing with the Dash network.".to_string()), + &OverlayConfig::new().with_progress_token(10), + ); + // Prime as already-shown at token 10 (the `!logged` path records the token). + log_overlay_state(&mut state); + assert_eq!(state.last_progress_token, Some(10)); + + // Age the clock past the watchdog with NO visible content change. + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + assert!(watchdog_tripped(state.last_progress_at)); + + // Token advances (height climbed) → clock resets, watchdog cleared — with + // the shown copy untouched. + let logged_before = state.logged_content.clone(); + state.progress_token = Some(20); + log_overlay_state(&mut state); + assert!( + !watchdog_tripped(state.last_progress_at), + "an advancing hidden token must reset the no-progress clock" + ); + assert_eq!( + state.logged_content, logged_before, + "a hidden token advance is NOT a shown content change (NFR-5)" + ); + + // Same token again (a true stall) → clock NOT reset, watchdog stays tripped. + state.last_progress_at = Instant::now() + .checked_sub(STUCK_OVERLAY_WATCHDOG_THRESHOLD + Duration::from_secs(5)) + .expect("instant underflow"); + let before = state.last_progress_at; + log_overlay_state(&mut state); + assert_eq!( + state.last_progress_at, before, + "an unchanged token must not reset the clock" + ); + assert!(watchdog_tripped(state.last_progress_at)); + } + /// A-1 — the watchdog dev-error flag flips once on render and stays set, so the /// error logs exactly once rather than every frame (NFR-5). #[test] fn watchdog_flag_flips_once_via_render() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global_spinner_only(&ctx); + let handle = ProgressOverlay::set_global_spinner_only(&ctx); { let mut stack = get_overlay_state(&ctx); let top = stack.iter_mut().find(|s| s.key == handle.key).unwrap(); diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 2fbe5f68f..063e12b22 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -5,7 +5,7 @@ //! renders an animated `egui::Spinner`, which self-requests an immediate repaint //! every frame — so these tests drive frames with `harness.step()` (one frame //! per queued event) instead of `harness.run()` (which would spin to the step -//! cap). `show_global` is called once via `harness.ctx`, not inside the +//! cap). `set_global` is called once via `harness.ctx`, not inside the //! per-frame closure, so the stack is not re-pushed each frame. //! //! Test ids map to `docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md`. @@ -21,7 +21,7 @@ //! inside the central panel. //! - TC-OVL-040 / TC-OVL-045 (log-once): covered by the inline unit tests in //! `src/ui/components/progress_overlay.rs` (`render_logs_once_then_marks_logged`); -//! the concurrent-request warning is emitted once in `show_global`, never per frame. +//! the concurrent-request warning is emitted once in `set_global`, never per frame. //! - TC-OVL-027 (no bare `ui.button()`) / TC-OVL-029 (no `set_enabled`): the //! renderer uses `ComponentStyles` button helpers and a top input-capturing //! layer, never `ui.button()` or the deprecated `Ui::set_enabled`. @@ -71,7 +71,7 @@ fn tc_ovl_001_idle_renders_nothing() { #[test] fn tc_ovl_002_overlay_appears_after_show() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Registering your identity.", OverlayConfig::default(), @@ -90,7 +90,7 @@ fn tc_ovl_002_overlay_appears_after_show() { #[test] fn tc_ovl_003_show_returns_usable_handle() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global(&ctx, "Loading.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Loading.", OverlayConfig::default()); assert!(handle.is_active()); assert!(handle.set_description("Updated text.").is_some()); assert!(ProgressOverlay::has_global(&ctx)); @@ -102,7 +102,7 @@ fn tc_ovl_003_show_returns_usable_handle() { #[test] fn tc_ovl_005_description_update_keeps_spinner() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Preparing the funding lock.", OverlayConfig::default(), @@ -135,7 +135,7 @@ fn tc_ovl_005_description_update_keeps_spinner() { #[test] fn tc_ovl_006_counter_update_changes_only_counter() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Processing.", OverlayConfig::new().with_step(2, 5), @@ -155,13 +155,13 @@ fn tc_ovl_006_counter_update_changes_only_counter() { #[test] fn tc_ovl_007_stale_handle_updates_are_none() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global(&ctx, "Soon gone.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Soon gone.", OverlayConfig::default()); handle.clone().clear(); assert!(handle.set_description("After clear").is_none()); assert!(handle.set_step(1, 3).is_none()); assert!( handle - .with_button("overlay.bg", "Run in background") + .with_action("Run in background", "overlay.bg") .is_none() ); assert!(!ProgressOverlay::has_global(&ctx)); @@ -173,7 +173,7 @@ fn tc_ovl_007_stale_handle_updates_are_none() { #[test] fn tc_ovl_008_programmatic_dismiss_removes_overlay() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); @@ -188,7 +188,7 @@ fn tc_ovl_008_programmatic_dismiss_removes_overlay() { #[test] fn tc_ovl_009_double_dismiss_is_noop() { let ctx = egui::Context::default(); - let handle = ProgressOverlay::show_global(&ctx, "Once.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&ctx, "Once.", OverlayConfig::default()); handle.clone().clear(); handle.clear(); assert!(!ProgressOverlay::has_global(&ctx)); @@ -199,7 +199,7 @@ fn tc_ovl_009_double_dismiss_is_noop() { #[test] fn tc_ovl_010_dismiss_before_error_banner() { let ctx = egui::Context::default(); - let overlay = ProgressOverlay::show_global(&ctx, "Registering.", OverlayConfig::default()); + let overlay = ProgressOverlay::set_global(&ctx, "Registering.", OverlayConfig::default()); assert!(overlay.is_active()); // Result arrives: lower the overlay, then show the banner — never both. @@ -221,11 +221,11 @@ fn tc_ovl_011_spinner_present_in_all_configs() { OverlayConfig::new().with_step(1, 3), OverlayConfig::new() .with_step(1, 3) - .with_button("overlay.bg", "Run in background"), + .with_action("Run in background", "overlay.bg"), ]; for config in configs { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global(&harness.ctx, "Busy.", config); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Busy.", config); harness.step(); assert!( harness.query_by_role(SPINNER_ROLE).is_some(), @@ -239,7 +239,7 @@ fn tc_ovl_011_spinner_present_in_all_configs() { #[test] fn tc_ovl_012_no_eta_or_percentage() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Building the shielded transaction.", OverlayConfig::new().with_step(2, 5), @@ -256,7 +256,7 @@ fn tc_ovl_012_no_eta_or_percentage() { #[test] fn tc_ovl_013a_elapsed_off_by_default() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); harness.step(); assert!(harness.query_by_label_contains("Elapsed:").is_none()); } @@ -269,7 +269,7 @@ fn tc_ovl_013a_elapsed_off_by_default() { #[test] fn tc_ovl_013b_elapsed_on_counts_up() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Slow operation.", OverlayConfig::new().with_elapsed(), @@ -298,7 +298,7 @@ fn tc_ovl_013b_elapsed_on_counts_up() { #[test] fn tc_ovl_014_valid_counter_renders() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Working.", OverlayConfig::new().with_step(3, 5), @@ -312,7 +312,7 @@ fn tc_ovl_014_valid_counter_renders() { fn tc_ovl_015_017_invalid_counter_hidden() { for (current, total) in [(0, 0), (4, 3), (0, 5)] { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Working.", OverlayConfig::new().with_step(current, total), @@ -330,7 +330,7 @@ fn tc_ovl_015_017_invalid_counter_hidden() { #[test] fn tc_ovl_019_no_counter_when_unset() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Sending your transaction to the network.", OverlayConfig::default(), @@ -351,7 +351,7 @@ fn tc_ovl_019_no_counter_when_unset() { #[test] fn tc_ovl_020_description_full_sentence() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Registering your identity on the network.", OverlayConfig::default(), @@ -373,7 +373,7 @@ fn tc_ovl_021_long_description_within_bounds() { .build_ui(|ui| { ProgressOverlay::render_global(ui.ctx()); }); - let _handle = ProgressOverlay::show_global(&harness.ctx, long, OverlayConfig::default()); + let _handle = ProgressOverlay::set_global(&harness.ctx, long, OverlayConfig::default()); harness.step(); let node = harness.query_by_label(long); @@ -397,7 +397,7 @@ fn tc_ovl_021_long_description_within_bounds() { #[test] fn tc_ovl_022_spinner_only_valid() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); assert!(harness.query_by_role(SPINNER_ROLE).is_some()); @@ -411,8 +411,7 @@ fn tc_ovl_022_spinner_only_valid() { #[test] fn tc_ovl_023_no_buttons_pure_block() { let mut harness = overlay_harness(); - let handle = - ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&harness.ctx, "Hard block.", OverlayConfig::default()); harness.step(); assert!(harness.query_by_label("Cancel").is_none()); assert!(handle.take_actions().is_empty()); @@ -425,10 +424,10 @@ fn tc_ovl_023_no_buttons_pure_block() { #[test] fn tc_ovl_024_button_click_enqueues_action() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Working.", - OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), ); // The centered card (anchored CENTER_CENTER) needs a few frames to cache its // size before it stops moving; settle before clicking so the click lands. @@ -449,10 +448,10 @@ fn tc_ovl_024_button_click_enqueues_action() { #[test] fn tc_ovl_025_generic_button_click_enqueues_action() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Background-able.", - OverlayConfig::new().with_button("overlay.run_in_bg", "Run in background"), + OverlayConfig::new().with_action("Run in background", "overlay.run_in_bg"), ); harness.step(); harness.step(); @@ -469,12 +468,12 @@ fn tc_ovl_025_generic_button_click_enqueues_action() { #[test] fn tc_ovl_026_action_queue_drains_fifo() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Two buttons.", OverlayConfig::new() - .with_button("primary", "Primary") - .with_secondary_button("secondary", "Secondary"), + .with_action("Primary", "primary") + .with_secondary_action("Secondary", "secondary"), ); // Settle the centered card before clicking (anchored CENTER_CENTER moves for // a couple of frames until its size is cached). @@ -500,12 +499,12 @@ fn tc_ovl_026_action_queue_drains_fifo() { #[test] fn tc_ovl_027_secondary_left_primary_right() { let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Two buttons.", OverlayConfig::new() - .with_button("primary", "Primary action") - .with_secondary_button("secondary", "Secondary action"), + .with_action("Primary action", "primary") + .with_secondary_action("Secondary action", "secondary"), ); harness.step(); @@ -532,7 +531,7 @@ fn tc_ovl_028_pointer_click_beneath_blocked() { } ProgressOverlay::render_global(ui.ctx()); }); - let handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); harness.get_by_label("Increment").click(); @@ -559,10 +558,10 @@ fn tc_ovl_029_keyboard_beneath_blocked() { ui.text_edit_singleline(&mut *buffer); ProgressOverlay::render_global(ui.ctx()); }); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Working.", - OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), ); harness.step(); @@ -581,7 +580,7 @@ fn tc_ovl_029_keyboard_beneath_blocked() { #[test] fn tc_ovl_030_backdrop_click_does_not_dismiss() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); @@ -603,7 +602,7 @@ fn tc_ovl_032_033_banner_persists_under_overlay() { MessageBanner::set_global(&ctx, "Banner A", MessageType::Error); MessageBanner::set_global(&ctx, "Banner B", MessageType::Warning); - let overlay = ProgressOverlay::show_global(&ctx, "Blocking.", OverlayConfig::default()); + let overlay = ProgressOverlay::set_global(&ctx, "Blocking.", OverlayConfig::default()); assert!(MessageBanner::has_global(&ctx)); assert!(ProgressOverlay::has_global(&ctx)); @@ -618,7 +617,7 @@ fn tc_ovl_032_033_banner_persists_under_overlay() { #[test] fn tc_ovl_034_dismiss_before_success_banner() { let ctx = egui::Context::default(); - let overlay = ProgressOverlay::show_global(&ctx, "Registering.", OverlayConfig::default()); + let overlay = ProgressOverlay::set_global(&ctx, "Registering.", OverlayConfig::default()); overlay.clear(); assert!(!ProgressOverlay::has_global(&ctx)); @@ -638,8 +637,8 @@ fn tc_ovl_034_dismiss_before_success_banner() { #[test] fn tc_ovl_036_topmost_entry_rendered() { let mut harness = overlay_harness(); - let a = ProgressOverlay::show_global(&harness.ctx, "Operation A.", OverlayConfig::default()); - let b = ProgressOverlay::show_global(&harness.ctx, "Operation B.", OverlayConfig::default()); + let a = ProgressOverlay::set_global(&harness.ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&harness.ctx, "Operation B.", OverlayConfig::default()); harness.step(); assert!(ProgressOverlay::has_global(&harness.ctx)); @@ -654,8 +653,8 @@ fn tc_ovl_036_topmost_entry_rendered() { #[test] fn tc_ovl_037_038_handle_dismisses_only_its_own() { let ctx = egui::Context::default(); - let a = ProgressOverlay::show_global(&ctx, "Operation A.", OverlayConfig::default()); - let b = ProgressOverlay::show_global(&ctx, "Operation B.", OverlayConfig::default()); + let a = ProgressOverlay::set_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_global(&ctx, "Operation B.", OverlayConfig::default()); b.clear(); assert!(a.is_active()); @@ -669,15 +668,15 @@ fn tc_ovl_037_038_handle_dismisses_only_its_own() { #[test] fn tc_ovl_039_only_topmost_actions_reachable() { let mut harness = overlay_harness(); - let a = ProgressOverlay::show_global( + let a = ProgressOverlay::set_global( &harness.ctx, "Operation A.", - OverlayConfig::new().with_button("cancel_a", "Cancel"), + OverlayConfig::new().with_action("Cancel", "cancel_a"), ); - let b = ProgressOverlay::show_global( + let b = ProgressOverlay::set_global( &harness.ctx, "Operation B.", - OverlayConfig::new().with_button("cancel_b", "Cancel"), + OverlayConfig::new().with_action("Cancel", "cancel_b"), ); // Settle the centered card before clicking (anchored CENTER_CENTER moves for // a couple of frames until its size is cached). @@ -711,10 +710,10 @@ fn tc_ovl_041_tab_focus_trap() { ui.text_edit_singleline(&mut *buffer); ProgressOverlay::render_global(ui.ctx()); }); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Working.", - OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), ); harness.step(); harness.step(); @@ -737,10 +736,10 @@ fn tc_ovl_041_tab_focus_trap() { #[test] fn tc_ovl_042_esc_swallowed_with_button() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Working.", - OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), ); harness.step(); @@ -757,8 +756,7 @@ fn tc_ovl_042_esc_swallowed_with_button() { #[test] fn tc_ovl_043_esc_swallowed_without_button() { let mut harness = overlay_harness(); - let handle = - ProgressOverlay::show_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&harness.ctx, "Hard block.", OverlayConfig::default()); harness.step(); harness.key_press(egui::Key::Escape); @@ -775,10 +773,10 @@ fn tc_ovl_043_esc_swallowed_without_button() { #[test] fn tc_ovl_044_enter_and_space_do_not_activate_button() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global( + let handle = ProgressOverlay::set_global( &harness.ctx, "Working.", - OverlayConfig::new().with_button("overlay.cancel", "Cancel"), + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), ); harness.step(); harness.step(); @@ -801,7 +799,7 @@ fn tc_ovl_044_enter_and_space_do_not_activate_button() { fn tc_ovl_046_theme_switch_mid_overlay() { let mut harness = overlay_harness(); harness.ctx.set_visuals(egui::Visuals::dark()); - let _handle = ProgressOverlay::show_global( + let _handle = ProgressOverlay::set_global( &harness.ctx, "Switching networks.", OverlayConfig::default(), @@ -835,7 +833,7 @@ fn tc_ovl_048_secret_prompt_renders_above_overlay() { }; passphrase_modal(ui.ctx(), &config, |_| {}); }); - let _handle = ProgressOverlay::show_global(&harness.ctx, "Signing.", OverlayConfig::default()); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Signing.", OverlayConfig::default()); harness.step(); harness.step(); @@ -873,7 +871,7 @@ fn tc_ovl_047_stuck_threshold_is_informational_only() { // Below the threshold a default overlay shows no elapsed readout and no // reassurance line — the reveal is purely time-driven and benign. let mut harness = overlay_harness(); - let _handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + let _handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); harness.step(); assert!(harness.query_by_label_contains("Elapsed:").is_none()); assert!( @@ -890,7 +888,7 @@ fn tc_ovl_047_stuck_threshold_is_informational_only() { #[test] fn tc_ovl_047b_threshold_reveals_via_clock_seam() { let mut harness = overlay_harness(); - let handle = ProgressOverlay::show_global(&harness.ctx, "Working.", OverlayConfig::default()); + let handle = ProgressOverlay::set_global(&harness.ctx, "Working.", OverlayConfig::default()); harness.step(); assert!(harness.query_by_label_contains("Elapsed:").is_none()); assert!( @@ -968,7 +966,7 @@ fn tc_ovl_050_component_instance_show_reports_click() { let overlay = Rc::new(RefCell::new( ProgressOverlay::new() .with_description("Instance overlay.") - .with_button("overlay.bg", "Run in background"), + .with_action("Run in background", "overlay.bg"), )); let overlay_ui = Rc::clone(&overlay); @@ -1035,7 +1033,7 @@ fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { harness.step(); // Raise a pure (button-less) block over the already-focused field. - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); // Type. AC-8.2: keyboard input must not reach widgets beneath the overlay. @@ -1106,7 +1104,7 @@ fn qa_buttonless_overlay_strips_edit_and_clipboard_events() { ); // Raise a button-less block over the focused, populated field. - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); // Inject edit + clipboard events; claim_input must strip them all. @@ -1167,7 +1165,7 @@ fn reconciliation_render_global_keeps_keyboard_for_prompt() { }); saw_keys_ui.set(survived); }); - let _handle = ProgressOverlay::show_global_spinner_only(&harness.ctx); + let _handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); assert!(!saw_keys.get(), "no keys injected yet"); @@ -1198,7 +1196,7 @@ fn reconciliation_instance_show_leaves_host_focus_navigable() { let overlay = Rc::new(RefCell::new( ProgressOverlay::new() .with_description("Inline overlay.") - .with_button("inline.act", "Act"), + .with_action("Act", "inline.act"), )); let overlay_ui = Rc::clone(&overlay); let text = Rc::new(RefCell::new(String::new())); @@ -1252,7 +1250,7 @@ fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { harness.set_size(egui::vec2(800.0, 600.0)); // Raise a button-less blocking overlay beneath the active prompt. - ProgressOverlay::show_global_spinner_only(&harness.ctx); + ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.run_steps(5); // The prompt renders above the overlay... @@ -1400,3 +1398,101 @@ fn task9_spv_overlay_armed_scope_disarm_and_escape() { ); }); } + +/// Item A (one-frame interactive gap) — driving the REAL `AppState::update` loop, +/// an armed SPV episode RAISES **and PAINTS** the block on the SAME frame it is +/// first observed, because `update_spv_overlay` now runs before `claim_input`, the +/// screen `ui()`, and `render_global`. Under the pre-fix order the block was raised +/// only after `render_global`, so it painted a frame late — leaving that frame +/// fully interactive. This test would need two `step()`s under the old order: the +/// single-frame paint assertion is the regression guard for the gap. +#[cfg(feature = "testing")] +#[test] +fn item_a_armed_episode_blocks_and_paints_same_frame() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // Arm a user-initiated episode and force Connecting, exactly as the + // Connect button / boot auto-start do — but BEFORE the first frame runs. + let app_context = app.current_app_context().clone(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_arm_spv_block(); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Exactly ONE frame. `update_spv_overlay` runs at frame start (before the + // input claim, the screen, and `render_global`), so the block is both raised + // and painted this same frame. + harness.step(); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "the armed episode raises the block" + ); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "the block is PAINTED on the same frame it is armed+observed — under the \ + pre-fix order (raise after render_global) it would only paint next frame, \ + leaving this frame interactive (the one-frame gap)" + ); + }); +} + +/// Item B (watchdog liveness) — using the `backdate` clock seam: a phase whose +/// elapsed exceeds the 120 s no-progress watchdog but whose hidden `progress_token` +/// ADVANCES must NOT trip the watchdog (a slow-but-advancing phase is real +/// progress); the same elapsed WITHOUT an advancing token MUST still trip it (a +/// genuine stall). The shown copy never changes, so the existing `(description, +/// step)` change-detection alone could not tell the two apart. +#[cfg(feature = "testing")] +#[test] +fn item_b_advancing_progress_token_resets_watchdog() { + let mut harness = overlay_harness(); + // Raise with a seed token; render once to log "shown" and record the token. + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new().with_progress_token(100), + ); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "no watchdog at the start" + ); + + // Age past the 120 s watchdog, but ADVANCE the hidden token before the next + // render — the shown description/step is untouched. + handle.backdate(Duration::from_secs(200)); + handle.set_progress_token(200); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_none(), + "an advancing progress token resets the watchdog — no false-stall escalation \ + even though the shown copy is unchanged" + ); + + // Age past the watchdog again WITHOUT advancing the token: a genuine stall must + // still trip it. + handle.backdate(Duration::from_secs(200)); + harness.step(); + assert!( + harness + .query_by_label_contains("much longer than expected") + .is_some(), + "a real stall (token unchanged) still trips the watchdog" + ); +} From 20723d2fd9aefd2bd941100f918c598449f15d2e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:05:59 +0200 Subject: [PATCH 18/22] feat(overlay): keyboard-reachable escape for the SPV hard block (QA-002 refinement) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the TODO(RUST-006) marker: the SPV-sync hard block's "Continue in the background" escape was mouse-only, stranding keyboard-only / assistive-tech users behind the UNBOUNDED block. Hard blocks strip Enter/Space every frame (the deliberate QA-002 rule, guarded by TC-OVL-044), so the escape could not be activated by keyboard. Add a per-block opt-in — `OverlayConfig::with_keyboard_escape(action_id)` and `OverlayHandle::with_keyboard_escape(action_id)` — that designates ONE action as the single keyboard-reachable escape. The general rule is unchanged: a block with no designated escape stays fully keyboard-blocked. - claim_input: when the active block designates an escape AND that escape button is *confirmed* to hold focus (its egui id was recorded by last frame's render_buttons and still matches the focused widget), Enter/Space pass through; every other key, and the raise frame (focus not yet confirmed), stays stripped. So the passthrough can never reach a widget beneath. - render_buttons: for an opt-in block, pin focus to the designated escape (match by action id) — re-requested every frame and locked — and record its id for the claim_input gate. - SPV adopter (update_spv_overlay): mark "Continue in the background" as the keyboard escape; it remains unconditionally present whenever the block is up. Tests (egui_kittest — the reliable check for input/focus): - TC-OVL-051/052: Enter / Space activate the focus-pinned escape. - TC-OVL-053: a TextEdit beneath never receives Enter; Tab and a backdrop click cannot move focus off the escape. - task9_spv_escape_is_keyboard_activatable: the REAL SPV block lowers on Enter. - TC-OVL-044 and the keyboard-block tests stay green (general rule intact). - Unit tests for the opt-in API + the claim_input safety gate. Docs: QA-002 design note + NFR-3 accessibility ACs, test-spec, user story UX-002, and the public rustdoc updated to state the refined rule. cargo +nightly fmt: clean. clippy --all-features --all-targets -D warnings: 0. kittest --all-features: 142 passed. lib --all-features: 928 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../01-requirements-ux.md | 17 +- .../02-test-spec.md | 63 ++++- .../03-dev-plan.md | 21 +- .../04-design-addendum.md | 13 + docs/user-stories.md | 1 + src/app.rs | 26 +- src/ui/components/README.md | 2 +- src/ui/components/progress_overlay.rs | 230 +++++++++++++++--- tests/kittest/progress_overlay.rs | 199 +++++++++++++++ 9 files changed, 515 insertions(+), 57 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md index 6ea8c19d2..e4df06199 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -206,12 +206,21 @@ project's i18n convention (Rust `{name}` specifiers today, Fluent `{ $name }` la ### NFR-3 — Accessibility (within egui's constraints) **AC-3a (focus trap):** while the overlay is up, keyboard focus is confined to its own -controls; Tab does not cycle into widgets beneath. +controls; Tab does not cycle into widgets beneath. When a block opts into a keyboard escape +(AC-3c), focus is *pinned* to that escape button — re-requested every frame and locked — so +neither Tab nor a click can move focus to a widget beneath. **AC-3b (Esc):** Esc triggers Cancel **only when the overlay is cancelable** (a Cancel action is present). With no Cancel, Esc is swallowed (does nothing) — it must not dismiss a -non-cancelable block. -**AC-3c (Enter):** Enter does not auto-confirm anything destructive; if a single primary -action exists, Enter MAY activate it, but Cancel is never the Enter default. +non-cancelable block. Esc never dismisses, even on an opt-in keyboard-escape block. +**AC-3c (Enter/Space — opt-in keyboard escape):** A hard block is **not** keyboard-activatable +by default: Enter/Space are stripped every frame, so a focused button cannot be triggered by +keyboard. The single exception is a block that opts in via `OverlayConfig::with_keyboard_escape`, +which designates one action as a keyboard-reachable escape. For that block — and only while its +escape button is confirmed to hold focus — Enter or Space activates the escape (egui fires a +fake primary click on either). This is for **unbounded** blocks that would otherwise strand a +keyboard-only / assistive-tech user; the reference adopter is the SPV-sync block, whose +"Continue in the background" escape is so designated. Every other hard block, and everything +beneath any block, stays fully keyboard-blocked. **AC-3d (not color-only):** "busy" is signalled by the moving spinner + text, not color alone. **AC-3e (contrast):** text/buttons meet WCAG 2.1 AA (4.5:1 text, 3:1 UI) on the dimmed plane in both themes. **AC-3f (known limitation):** egui has no screen-reader annotation support (per diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md index 63b83854f..c64da53ee 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -939,8 +939,65 @@ There is no built-in Cancel, so Esc has nothing to trigger. - `take_actions` returns empty (Enter did NOT enqueue the focused button's action). - The overlay is still active. -**Rationale:** A hard block swallows Tab/Enter/Esc, so a focused button can never be activated by -keyboard — Enter must not trigger it. See AC-3c. +**Rationale:** A hard block swallows Tab/Enter/Esc/Space, so a focused button can never be +activated by keyboard — Enter/Space must not trigger it. This is the guard that the general rule +stays intact; the single opt-in exception is covered by TC-OVL-051/052/053. See AC-3c. + +--- + +#### TC-OVL-051 — Opt-in keyboard escape activates on Enter +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** Overlay active with a secondary action also designated via +`with_keyboard_escape(action_id)`; settle frames so the escape button holds focus. + +**Steps:** +1. Show the overlay with the designated escape; run frames until the escape button is focused. +2. Press Enter; run one frame. +3. Call `take_actions`. + +**Expected outcome:** +- `take_actions` returns the escape's action id (Enter activated the focus-pinned escape). +- The overlay is still active (activation enqueues the id; the owner lowers the block). + +**Rationale:** An unbounded block that opts into a keyboard escape must be activatable by Enter so +a keyboard-only / assistive-tech user is not stranded. See AC-3c. + +--- + +#### TC-OVL-052 — Opt-in keyboard escape activates on Space +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** As TC-OVL-051. + +**Steps:** As TC-OVL-051, pressing **Space** instead of Enter. + +**Expected outcome:** `take_actions` returns the escape's action id (egui fires a fake primary +click on Space OR Enter for the focused widget). + +--- + +#### TC-OVL-053 — Opt-in keyboard escape is focus-pinned (no leak beneath) +**Type:** kittest +**Traceability:** NFR-3 (AC-3a, AC-3c) + +**Preconditions:** A `TextEdit` rendered beneath; overlay active with a designated keyboard escape; +settle frames so the escape holds focus. + +**Steps:** +1. Assert the escape button is focused (not the field beneath). +2. Press Tab; assert focus stays on the escape. +3. Click over the field beneath; assert focus stays on the escape (the click is absorbed by the + sink and the per-frame focus pin restores it). +4. Press Enter; assert `take_actions` returns the escape id AND the field beneath is still empty. + +**Expected outcome:** Neither Tab nor a click can move focus off the escape, and Enter/Space reach +only the escape — never a widget beneath. The opt-in carves out Enter/Space for the escape alone. + +**Rationale:** The Enter/Space passthrough is safe only because focus is guaranteed pinned to the +escape. See AC-3a, AC-3c. --- @@ -1079,7 +1136,7 @@ loop). | FR-10 (concurrent operations) | TC-OVL-036, TC-OVL-037, TC-OVL-038, TC-OVL-039, TC-OVL-040 | | NFR-1 (no frame blocking) | TC-OVL-004, TC-OVL-049 | | NFR-2 (i18n-ready strings) | TC-OVL-014 (counter format), TC-OVL-020 (description), TC-OVL-013 (elapsed) | -| NFR-3 (accessibility) | TC-OVL-041, TC-OVL-042, TC-OVL-043, TC-OVL-044 | +| NFR-3 (accessibility) | TC-OVL-041, TC-OVL-042, TC-OVL-043, TC-OVL-044, TC-OVL-051, TC-OVL-052, TC-OVL-053 | | NFR-4 (theme) | TC-OVL-046 | | NFR-5 (log-once) | TC-OVL-045, TC-OVL-040 | | NFR-6 (cheap idle) | TC-OVL-001 | diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index f8be7834e..03b3e7299 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -18,8 +18,11 @@ Cancel-specific decisions below. Where this document and the redesign disagree, caller attaches a generic button via `OverlayConfig::with_action(label, id)` / `OverlayHandle::with_action(label, id)`, choosing its own opaque action id and label. Clicking enqueues the id; the owning screen drains it via `take_actions` and runs whatever logic it wants - — including its own cancellation. Esc/Tab/Enter are swallowed (a hard block is never keyboard- - dismissable); there is no Esc→Cancel routing. This **supersedes D-5** (the shipped-but-unwired + — including its own cancellation. Esc/Tab/Enter/Space are swallowed (a hard block is never + keyboard-dismissable or keyboard-activatable), with one opt-in exception: a block may designate a + single keyboard-reachable escape via `with_keyboard_escape(id)`, after which Enter/Space activate + that focus-pinned button (the unbounded SPV block uses this — see QA-002 below). There is no + Esc→Cancel routing. This **supersedes D-5** (the shipped-but-unwired Cancel API) and the Cancel-specific parts of **FR-7** — "Cancel" is now merely one possible caller-chosen label on a generic button, not a built-in concept. 2. **`Component` trait conformance (placement legitimacy).** `ProgressOverlay` now implements the @@ -399,12 +402,16 @@ Use a top input-capturing layer instead — the same shape the passphrase modal - **Esc:** if the topmost entry has a Cancel button → enqueue its action id and consume (TC-OVL-042); otherwise consume-and-swallow (TC-OVL-043). Never dismisses a non-cancelable block. - - **Enter:** never activates Cancel (TC-OVL-044). If a single primary action exists, Enter MAY - activate it (AC-3c). + - **Enter/Space:** stripped every frame, so a focused button is never keyboard-activatable + (TC-OVL-044) — EXCEPT on a block that opts in via `with_keyboard_escape(id)`. For that block, + and only while its escape button is confirmed to hold focus, Enter/Space pass through and + activate the focus-pinned escape (TC-OVL-051/052/053, AC-3c). The unbounded SPV block uses this + so keyboard-only users are not stranded. - **Tab:** consumed at the overlay layer (focus trap, TC-OVL-041); focus is requested onto the - first overlay button on raise so it cannot escape beneath. Implemented via - `ctx.input_mut(|i| i.events.retain(...))` filtering Tab/Esc/Enter while active — scoped to the - overlay-active branch so global shortcuts are untouched when idle. + overlay's focus target on raise — the designated escape when one is opted in, else the first + button — so it cannot escape beneath. Implemented via `ctx.input_mut(|i| i.events.retain(...))` + filtering Tab/Esc/Enter/Space while active (carving out Enter/Space for a confirmed escape) — + scoped to the overlay-active branch so global shortcuts are untouched when idle. 4. **Card:** `egui::Area::new(Id::new("__overlay_card")).order(Order::Middle) .anchor(Align2::CENTER_CENTER, Vec2::ZERO)` → `egui::Frame` (fill `surface`/`window_fill`, `Shadow::elevated()`, `RADIUS_LG`) with a min width and a vertical layout. Long descriptions diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md index f82c3a4e2..ed23f1fb0 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -101,6 +101,19 @@ currently `#[ignore]`). Fix: add `ProgressOverlay::claim_input(ctx)`, called **n buttons (post-T7), `claim_input` still strips text and beneath-navigation, and the overlay's own button area re-grants only its buttons' navigation via the existing `set_focus_lock_filter`. + - **QA-002 refinement — one opt-in keyboard escape.** A hard block is never keyboard-activatable + (Enter/Space stripped every frame), so a focused button cannot be triggered by keyboard. The + one exception is a block that opts in via `OverlayConfig::with_keyboard_escape(action_id)`: it + designates a single action as a keyboard-reachable escape, for **unbounded** blocks that would + otherwise strand a keyboard-only / assistive-tech user. `claim_input` then keeps Enter/Space — + but **only while that escape button is confirmed to hold focus** (its egui id was recorded by + the previous frame's `render_buttons` and still matches the focused widget). Because the escape + is focus-pinned (re-requested every frame + locked), Enter/Space can never reach a widget + beneath; on the raise frame, where focus is not yet confirmed, they are still stripped. The + reference adopter is the unbounded SPV-sync block (`update_spv_overlay`), whose "Continue in + the background" escape is so designated. Every OTHER hard block stays fully keyboard-blocked + (`TC-OVL-044` is the guard that the general rule is intact; `TC-OVL-051/052/053` cover the + opt-in escape). ### Rationale diff --git a/docs/user-stories.md b/docs/user-stories.md index 7a9989f72..aa3593294 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1144,5 +1144,6 @@ As a user, while the app connects to and syncs the Dash chain on startup or afte - While that startup/Connect sync is getting connected, a full-window block appears with a plain please-wait sentence ("Connecting to the Dash network." / "Syncing with the Dash network.") and a friendly progress indicator ("Step N of 5") — no blockchain jargon, raw heights, or percentages. - The block always offers a secondary "Continue in the background" button. Clicking it lowers the block; sync keeps running in the background (it is read-only and strands nothing), and the block is not re-raised for the rest of that sync episode. +- The "Continue in the background" escape is reachable by **keyboard**, not just the mouse: it is the one designated keyboard escape on this otherwise keyboard-blocked block, so a keyboard-only or assistive-technology user can activate it with Enter or Space and is never trapped behind the unbounded sync. Focus is pinned to that button, so Enter/Space (and Tab/clicks) can never reach a widget beneath the block. - The block is scoped to *user-initiated* sync (startup auto-start / Connect): it lowers on its own when the chain becomes usable (Synced) or fails (Error), and an **ambient** reconnect or per-block catch-up afterward does not block a working user. Pressing Connect (or a fresh startup) blocks again. - This is the overlay's first real adopter (PR #863). Unlike the unsafe-to-interrupt operations in UX-001, SPV sync is **unbounded but safe to background** — so its C2 "never trap the user" guarantee is met by the always-on escape, not by operation boundedness. diff --git a/src/app.rs b/src/app.rs index c8566fe0a..711e0d8ce 100644 --- a/src/app.rs +++ b/src/app.rs @@ -69,8 +69,11 @@ pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; /// with no terminal signal — so a button-less hard block would trap the user /// (violating the overlay's C1/C2 contract). This escape lowers the block while /// sync continues safely in the background — a read-only operation that strands -/// nothing if backgrounded. Colon-namespaced per the overlay action-id -/// convention. Exposed for kittest coverage. +/// nothing if backgrounded. It is also designated the block's single +/// keyboard-reachable escape (`with_keyboard_escape`), so a keyboard-only / +/// assistive-tech user can activate it with Enter or Space (QA-002 refinement). +/// Colon-namespaced per the overlay action-id convention. Exposed for kittest +/// coverage. pub const SPV_CONTINUE_BACKGROUND_ACTION: &str = "spv:sync:continue_background"; /// Plain, jargon-free descriptions for the SPV-sync block (Everyday-User rule: @@ -1149,9 +1152,10 @@ impl AppState { /// C2 contract: SPV sync is **unbounded** — with no peers it stays /// Connecting/Syncing forever with no terminal signal — so a button-less block /// would trap the user. The block therefore carries a "Continue in the - /// background" escape ([`SPV_CONTINUE_BACKGROUND_ACTION`]); clicking it lowers - /// the block while sync proceeds safely in the background (read-only — nothing - /// is stranded). + /// background" escape ([`SPV_CONTINUE_BACKGROUND_ACTION`]); clicking it — or + /// activating it by keyboard, as it is the block's designated + /// `with_keyboard_escape` (QA-002 refinement) — lowers the block while sync + /// proceeds safely in the background (read-only — nothing is stranded). /// /// Raises the overlay at most once per episode (then updates content in place /// via the handle), so it never `set_global`s every frame. @@ -1175,16 +1179,18 @@ impl AppState { SPV_CONNECTING_DESCRIPTION }; if self.spv_overlay.is_none() { - // TODO(RUST-006): provide a keyboard/assistive-tech exit for the - // UNBOUNDED SPV block (hard blocks are intentionally not - // keyboard-activatable per QA-002, but the unbounded SPV case can - // strand keyboard-only users) — pending product decision. + // The escape is the single keyboard-reachable exit (QA-002 + // refinement): the overlay focus-pins this button and lets + // Enter/Space activate it, so a keyboard-only / assistive-tech + // user is never stranded behind the UNBOUNDED SPV block while + // every other hard block stays fully keyboard-blocked. let mut config = OverlayConfig::new() .with_description(description) .with_secondary_action( "Continue in the background", SPV_CONTINUE_BACKGROUND_ACTION, - ); + ) + .with_keyboard_escape(SPV_CONTINUE_BACKGROUND_ACTION); if let Some(n) = step { config = config.with_step(n, SPV_SYNC_PHASE_COUNT); } diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 5609ffaa5..690c5c961 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -34,7 +34,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | Description | |-----------|------|-------------| | `MessageBanner` | `message_banner.rs` | Global error/warning/success/info banners. `set_global()`, `with_details()`, auto-dismiss. Extensions: `OptionBannerExt`, `OptionBannerShowExt`, `ResultBannerExt` | -| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_action(label, action_id)`, mirrors `MessageBanner::with_action`), 120s watchdog. Global path: `set_global()` (raise, mirrors `MessageBanner::set_global`) / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt` (`raise` — the banner's `replace`, renamed to dodge inherent `Option::replace`), `ProgressOverlayResponse` | +| `ProgressOverlay` | `progress_overlay.rs` | Full-screen blocking progress overlay: spinner, optional step counter, generic buttons (`with_action(label, action_id)`, mirrors `MessageBanner::with_action`), 120s watchdog. A hard block is never keyboard-activatable except via `with_keyboard_escape(action_id)`, which designates one focus-pinned button as a keyboard-reachable escape (Enter/Space) for unbounded blocks (the SPV-sync block). Global path: `set_global()` (raise, mirrors `MessageBanner::set_global`) / `render_global()`, claims input each frame. Companions: `OverlayConfig`, `OverlayHandle`, `OptionOverlayExt` (`raise` — the banner's `replace`, renamed to dodge inherent `Option::replace`), `ProgressOverlayResponse` | ## Styled Components (`styled.rs`) diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index ba989cbdf..7125c2348 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -162,6 +162,17 @@ struct OverlayState { watchdog_logged: bool, /// Set once focus has been placed on the first button (focus trap). focus_requested: bool, + /// Opt-in action id designated as the single keyboard-reachable escape (QA-002 + /// refinement). When set, `claim_input` lets Enter/Space through to its focused + /// button while still stripping every other key; every other hard block stays + /// fully keyboard-blocked. `None` by default — a block is keyboard-blocked + /// unless it opts in. + keyboard_escape_action: Option, + /// The egui id of the rendered escape button, recorded by `render_buttons` each + /// frame the block paints. `claim_input` reads it the following frame to confirm + /// focus is pinned to the escape before allowing Enter/Space through, so the + /// passthrough can never reach a widget beneath. + escape_focus_id: Option, } impl OverlayState { @@ -181,6 +192,8 @@ impl OverlayState { last_progress_at: now, watchdog_logged: false, focus_requested: false, + keyboard_escape_action: config.keyboard_escape_action.clone(), + escape_focus_id: None, } } } @@ -197,6 +210,10 @@ pub struct OverlayConfig { /// /// [`with_progress_token`]: Self::with_progress_token progress_token: Option, + /// Opt-in keyboard escape (QA-002 refinement); see [`with_keyboard_escape`]. + /// + /// [`with_keyboard_escape`]: Self::with_keyboard_escape + keyboard_escape_action: Option, } impl OverlayConfig { @@ -262,6 +279,25 @@ impl OverlayConfig { .push(OverlayButton::new(action_id, label, ButtonStyle::Secondary)); self } + + /// Designate one already-added action as the single **keyboard-reachable + /// escape** (QA-002 refinement). Pass the same opaque `action_id` you gave to + /// [`with_action`](Self::with_action) / [`with_secondary_action`](Self::with_secondary_action). + /// + /// A hard block is otherwise never keyboard-activatable: `claim_input` strips + /// every navigation/confirm key every frame so a focused button cannot be + /// triggered by keyboard. This opt-in carves out exactly one exception — the + /// designated button stays activatable with Enter or Space — for blocks that are + /// **unbounded** and would otherwise strand a keyboard-only / assistive-tech user + /// (the reference adopter is the SPV-sync block, whose sync can wait + /// indefinitely for peers). The overlay focus-pins the escape button so the + /// passthrough can never reach a widget beneath; every OTHER hard block, and + /// everything beneath, stays fully keyboard-blocked. Designating an action id + /// that no button carries is a no-op. + pub fn with_keyboard_escape(mut self, action_id: impl fmt::Display) -> Self { + self.keyboard_escape_action = Some(action_id.to_string()); + self + } } /// Lifecycle handle for a raised overlay, returned by @@ -353,6 +389,15 @@ impl OverlayHandle { self.mutate(|s| s.progress_token = Some(token)) } + /// Designate one already-attached action as the single keyboard-reachable + /// escape, mirroring [`OverlayConfig::with_keyboard_escape`]. Pass the same + /// opaque `action_id` you gave to a `with_action` / `with_secondary_action` + /// call. Returns `None` if the entry is gone. + pub fn with_keyboard_escape(&self, action_id: impl fmt::Display) -> Option<&Self> { + let action_id = action_id.to_string(); + self.mutate(|s| s.keyboard_escape_action = Some(action_id)) + } + /// Drain (FIFO) and remove the action ids enqueued by **this handle's** /// button clicks, leaving other overlay entries' actions untouched (A-3). /// @@ -665,9 +710,16 @@ impl ProgressOverlay { /// Backspace, Delete, Home, End, PageUp, PageDown) from `i.events` so /// nothing beneath observes them. /// - /// A hard block is never keyboard-dismissable or keyboard-activatable. The - /// buttoned case (post-T7) re-grants its own buttons' navigation via the - /// focus-lock filter in `render_buttons`. + /// A hard block is never keyboard-dismissable or keyboard-activatable, with one + /// opt-in exception: a block that designates a single keyboard escape via + /// [`OverlayConfig::with_keyboard_escape`] keeps **Enter and Space** so its + /// focused escape button can be activated (egui fires a fake primary click on + /// either for the focused widget). Even then this only happens once the escape + /// button is *confirmed* to hold focus (its id was recorded by `render_buttons` + /// the previous frame and still matches the focused widget) — so Enter/Space can + /// never reach a widget beneath; every other key, and every non-opted block, + /// stays fully blocked. The buttoned case re-grants its own button's navigation + /// via the focus-lock filter in `render_buttons`. pub fn claim_input(ctx: &egui::Context) { let stack = get_overlay_state(ctx); let Some(top) = stack.last() else { @@ -682,32 +734,57 @@ impl ProgressOverlay { if top.buttons.is_empty() { ctx.memory_mut(|m| m.stop_text_input()); } + // Let Enter/Space through ONLY while a designated escape button is confirmed + // to hold focus. `escape_focus_id` is recorded by last frame's render and the + // focus check rejects the raise frame (id not yet recorded) and any frame + // where focus has drifted — so the passthrough can never reach a beneath + // widget. Everything else is stripped exactly as for a plain hard block. + let escape_confirmed = top.keyboard_escape_action.is_some() + && top.escape_focus_id.is_some() + && ctx.memory(|m| m.focused()) == top.escape_focus_id; ctx.input_mut(|i| { i.events.retain(|e| { - !matches!( + if matches!( e, egui::Event::Text(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Paste(_) - | egui::Event::Key { - key: egui::Key::Tab - | egui::Key::Enter - | egui::Key::Escape - | egui::Key::Space - | egui::Key::ArrowUp - | egui::Key::ArrowDown - | egui::Key::ArrowLeft - | egui::Key::ArrowRight - | egui::Key::Backspace - | egui::Key::Delete - | egui::Key::Home - | egui::Key::End - | egui::Key::PageUp - | egui::Key::PageDown, + ) { + return false; + } + if escape_confirmed + && matches!( + e, + egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, pressed: true, .. } + ) + { + return true; + } + !matches!( + e, + egui::Event::Key { + key: egui::Key::Tab + | egui::Key::Enter + | egui::Key::Escape + | egui::Key::Space + | egui::Key::ArrowUp + | egui::Key::ArrowDown + | egui::Key::ArrowLeft + | egui::Key::ArrowRight + | egui::Key::Backspace + | egui::Key::Delete + | egui::Key::Home + | egui::Key::End + | egui::Key::PageUp + | egui::Key::PageDown, + pressed: true, + .. + } ) }); }); @@ -1040,20 +1117,28 @@ fn render_card( /// Within each tier, buttons render in the order they were added. Returns the /// clicked button's action id, if any. Clicks never lower the overlay. /// -/// `trap_focus` is `true` only for the global full-window block: the first -/// button is focused on raise and a focus-lock filter traps Tab/arrows/Esc on it -/// so keyboard navigation cannot escape to a widget beneath the block. The -/// instance [`Component`] path passes `false` (QA-003) so an inline, non-blocking -/// widget never seizes the host screen's focus. +/// `trap_focus` is `true` only for the global full-window block: a button is +/// focused on raise and a focus-lock filter traps Tab/arrows/Esc on it so keyboard +/// navigation cannot escape to a widget beneath the block. The focused button is +/// the **designated keyboard escape** when the block opts into one (QA-002 +/// refinement) — so Enter/Space, which `claim_input` lets through only for an +/// opted-in block, activate the escape and nothing else — otherwise the first +/// button. The instance [`Component`] path passes `false` (QA-003) so an inline, +/// non-blocking widget never seizes the host screen's focus. fn render_buttons( ui: &mut egui::Ui, state: &mut OverlayState, dark_mode: bool, trap_focus: bool, ) -> Option { - let want_focus = trap_focus && !state.focus_requested; + let escape_action = state.keyboard_escape_action.clone(); + // Re-request focus every frame for an opt-in escape so the escape can never be + // left un-focused (which would silently disable the keyboard exit); a non-escape + // block requests once and relies on the lock, as before. + let want_focus = trap_focus && (!state.focus_requested || escape_action.is_some()); let mut clicked = None; - let mut focus_stop = None; + let mut first_id = None; + let mut escape_id = None; ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Primaries first → rightmost (accent); secondaries after → to their left. @@ -1074,11 +1159,11 @@ fn render_buttons( ComponentStyles::add_secondary_button(ui, &button.label, dark_mode) } }; - if focus_stop.is_none() { - focus_stop = Some(response.id); - if want_focus { - response.request_focus(); - } + if first_id.is_none() { + first_id = Some(response.id); + } + if escape_action.as_deref() == Some(button.action_id.as_str()) { + escape_id = Some(response.id); } if response.clicked() { clicked = Some(button.action_id.clone()); @@ -1086,7 +1171,12 @@ fn render_buttons( } }); - if trap_focus && let Some(id) = focus_stop { + // Pin focus to the designated escape if present, else the first button. + let focus_target = escape_id.or(first_id); + if trap_focus && let Some(id) = focus_target { + if want_focus { + ui.memory_mut(|m| m.request_focus(id)); + } // Trap keyboard focus on the block. egui resolves Tab/arrow navigation in // `begin_pass` (before this code runs), so filtering those key events here // is too late — only a focus lock filter on the focused widget keeps @@ -1105,6 +1195,10 @@ fn render_buttons( }); state.focus_requested = true; } + // Record the escape button's id so the next frame's `claim_input` can confirm + // focus is pinned to it before letting Enter/Space through. `None` (no opt-in or + // instance path) keeps the passthrough closed. + state.escape_focus_id = escape_id; clicked } @@ -1445,6 +1539,78 @@ mod tests { ); } + /// QA-002 refinement — `with_keyboard_escape` records the designated escape + /// action id on both the config (via `set_global`) and a live handle. + #[test] + fn with_keyboard_escape_records_action_via_config_and_handle() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_global( + &ctx, + "Syncing.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + let read = |key: u64| { + get_overlay_state(&ctx) + .into_iter() + .find(|s| s.key == key) + .and_then(|s| s.keyboard_escape_action) + }; + assert_eq!(read(handle.key).as_deref(), Some("spv:escape")); + + // The handle-side mutator designates the escape on a block raised without it. + let plain = ProgressOverlay::set_global( + &ctx, + "Working.", + OverlayConfig::new().with_secondary_action("Continue", "later"), + ); + assert!(read(plain.key).is_none()); + assert!(plain.with_keyboard_escape("later").is_some()); + assert_eq!(read(plain.key).as_deref(), Some("later")); + } + + /// QA-002 refinement (safety gate) — even with an escape designated, + /// `claim_input` STILL strips Enter/Space until the escape button is confirmed + /// to hold focus. Here no render has run, so `escape_focus_id` is unset and the + /// passthrough must stay closed — the keyboard exit can never reach a beneath + /// widget on the raise frame. + #[test] + fn claim_input_strips_enter_space_until_escape_focus_confirmed() { + let ctx = egui::Context::default(); + ProgressOverlay::set_global( + &ctx, + "Syncing.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + let leaked = std::cell::Cell::new(true); + let raw = egui::RawInput { + events: vec![key_down(egui::Key::Enter), key_down(egui::Key::Space)], + ..Default::default() + }; + let _ = ctx.run(raw, |ctx| { + ProgressOverlay::claim_input(ctx); + ctx.input(|i| { + leaked.set(i.events.iter().any(|e| { + matches!( + e, + egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, + pressed: true, + .. + } + ) + })); + }); + }); + assert!( + !leaked.get(), + "Enter/Space must stay stripped until the escape button is confirmed focused" + ); + } + /// `claim_input` is a no-op when no overlay is active — it must not eat input /// from the rest of the app. #[test] diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 063e12b22..72da081e7 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -792,6 +792,146 @@ fn tc_ovl_044_enter_and_space_do_not_activate_button() { assert!(ProgressOverlay::has_global(&harness.ctx)); } +/// TC-OVL-051 — a block that opts into a keyboard escape via `with_keyboard_escape` +/// (QA-002 refinement) CAN be activated with **Enter**: the focus-pinned escape +/// button fires and enqueues its action — the keyboard exit the unbounded SPV +/// block relies on. The general rule (TC-OVL-044) is unchanged for non-opted blocks. +#[test] +fn tc_ovl_051_designated_escape_activates_on_enter() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + // Settle: focus the escape and let the focus lock take effect (it is a no-op + // until the button has held focus for a frame). + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "the designated escape button holds focus" + ); + + harness.key_press(egui::Key::Enter); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Enter activates the focus-pinned escape and enqueues its action" + ); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "activating the escape does not itself lower the overlay — the owner does" + ); +} + +/// TC-OVL-052 — the opted-in keyboard escape also activates with **Space** (egui +/// fires a fake primary click on Space OR Enter for the focused widget). +#[test] +fn tc_ovl_052_designated_escape_activates_on_space() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused() + ); + + harness.key_press(egui::Key::Space); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Space activates the focus-pinned escape and enqueues its action" + ); +} + +/// TC-OVL-053 — the keyboard escape is focus-pinned: a `TextEdit` placed beneath +/// the block never receives the Enter (it activates the escape, not the field), and +/// neither Tab nor a backdrop click can move focus off the escape to a beneath +/// widget. The opt-in carves out Enter/Space ONLY for the escape; everything +/// beneath stays fully keyboard-blocked. +#[test] +fn tc_ovl_053_designated_escape_is_focus_pinned() { + let text = Rc::new(RefCell::new(String::new())); + let text_ui = Rc::clone(&text); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx()); + }); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "focus is pinned to the escape, not the field beneath" + ); + + // Tab cannot move focus off the escape (claim_input strips it; the lock backs it). + harness.key_press(egui::Key::Tab); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "Tab cannot move focus to the field beneath" + ); + + // A click over the field beneath is absorbed by the sink and cannot move focus; + // the per-frame focus pin keeps the escape focused. + let over_field = egui::pos2(20.0, 20.0); + harness.drag_at(over_field); + harness.drop_at(over_field); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "a click over the field beneath cannot move focus off the escape" + ); + + // Enter activates the escape — and never reaches the field beneath. + harness.key_press(egui::Key::Enter); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "Enter activates the escape" + ); + assert!( + text.borrow().is_empty(), + "the field beneath the block never received the Enter" + ); +} + // ── Group M — Non-Functional ─────────────────────────────────────────────── /// TC-OVL-046 — switching theme mid-overlay re-renders without panic. @@ -1399,6 +1539,65 @@ fn task9_spv_overlay_armed_scope_disarm_and_escape() { }); } +/// Task 9 / QA-002 refinement — the REAL SPV block's "Continue in the background" +/// escape is keyboard-activatable: pressing **Enter** while it holds focus enqueues +/// its action, which the driver drains to lower the block. Guards the app.rs wiring +/// (`with_keyboard_escape(SPV_CONTINUE_BACKGROUND_ACTION)`) so a keyboard-only / +/// assistive-tech user is never stranded behind the unbounded SPV block. +#[cfg(feature = "testing")] +#[test] +fn task9_spv_escape_is_keyboard_activatable() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + // Mirror AppState::update: claim input at frame start, then render. + let mut harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::claim_input(ui.ctx()); + ProgressOverlay::render_global(ui.ctx()); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + let app_context = app.current_app_context().clone(); + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + + // Arm a user-initiated episode and raise the block. + app.test_arm_spv_block(); + app.test_drive_spv_overlay(&harness.ctx); + // Settle focus on the escape button (the focus lock is a no-op until the + // button has held focus for a frame). + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .get_by_label("Continue in the background") + .is_focused(), + "the SPV escape button holds focus" + ); + + // Enter activates the escape; the next driver pass drains it and lowers the + // block for the rest of the episode (sync keeps running in the background). + harness.key_press(egui::Key::Enter); + harness.step(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "Enter on the SPV escape lowers the block — a keyboard-only user is never trapped" + ); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block stays lowered within the dismissed episode" + ); + }); +} + /// Item A (one-frame interactive gap) — driving the REAL `AppState::update` loop, /// an armed SPV episode RAISES **and PAINTS** the block on the SAME frame it is /// first observed, because `update_spv_overlay` now runs before `claim_input`, the From f50cbcad35cdf42ad0f05720ab2c76d831b5ecbc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:14:25 +0200 Subject: [PATCH 19/22] fix(overlay): activate keyboard escape at frame start (SEC-001, SEC-002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The opt-in keyboard escape used to "keep" Enter/Space in `i.events` only while the escape button was confirmed-focused, and `render_buttons` re-requested that focus every frame. Two bugs fell out of it: - SEC-001: `render_global` runs before `render_secret_prompt`, so the per-frame focus re-request stole focus from a passphrase modal raised above the block — the field went un-typeable and Enter fired the escape instead of submitting. Realistic on a cold-start migration prompt over the startup SPV auto-sync block. - SEC-002: the kept Enter/Space reached the beneath screen's `ui()` (which runs before `render_global`), so a focus-independent global key handler beneath (info_popup / selection_dialog / address_input) observed the key — a single Enter/Space leaked through the "hard" block. Unified fix: move escape activation to frame start in `claim_input`. When a block designates a `keyboard_escape_action` and Enter/Space is pressed, enqueue that action directly (the same queue a click feeds) and strip the key with all the others. Activation no longer needs the button focused (SEC-001) and the key never survives to a widget beneath (SEC-002). Focus on the escape is now purely visual and is suppressed while a secret prompt is active — `render_global` takes a `secret_prompt_active` flag mirroring the `claim_overlay_input` gate. A non-opted block still strips Enter/Space and activates nothing; Esc still never dismisses. Drops the now-dead `escape_focus_id` field and confirmed-focus logic. Also in this rework: - SEC-003 residual: TODO documenting the narrow constant-height >120s watchdog false-alarm (benign log + accurate copy, no abort) pending a coarser SDK liveness signal. - RUST-001: `keyboard_escape_action.clone()` -> `as_deref()` in render_buttons (no per-frame String alloc). - RUST-002: corrected the stale `log_overlay_state` call comment to note the watchdog also resets on a hidden progress_token advance. - PROJ-001: render_global rustdoc now cross-references `MessageBanner::show_global` for the set_global/render_global vs set_global/show_global asymmetry. Tests (egui_kittest, the authority for input/focus): - sec001_* drives the real AppState loop with an escape block beneath an active secret prompt: the prompt keeps focus, Enter submits it, the escape action is never enqueued. - sec002_* a focus-independent `key_pressed(Enter)` sentinel beneath an escape block never fires; the Enter is stripped and routed to the escape. - Replaced the obsolete confirmed-focus unit test with one asserting the frame-start enqueue + strip. TC-OVL-044/048/051/052/053, rq1, and task9 escape tests stay green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 17 ++- src/ui/components/progress_overlay.rs | 157 +++++++++++++++----------- tests/kittest/progress_overlay.rs | 144 +++++++++++++++++++++-- 3 files changed, 237 insertions(+), 81 deletions(-) diff --git a/src/app.rs b/src/app.rs index 711e0d8ce..c9652bf20 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1172,6 +1172,17 @@ impl AppState { // height so a slow-but-advancing phase (whose "Step N of 5" stays // constant for minutes) never trips the no-progress watchdog. It is // never rendered — no height/number leaks into the shown copy. + // + // TODO(SEC-003-constant-height): the narrow residual is a phase that + // stays Syncing at a CONSTANT height for >120s (e.g. a single large + // masternode-list diff, step 2) — its height never moves, so the + // token never advances and the generic 120s watchdog still trips its + // one-shot dev-error + "much longer than expected" copy. It does NOT + // abort (SPV is known-unbounded and carries the keyboard escape), and + // the copy is accurate, so this is a benign false alarm. A clean fix + // needs a coarser SDK liveness signal (bytes/diffs processed) folded + // into the token without breaking its documented monotonicity; the + // SDK does not expose one today. Tracked as a follow-up. let token = progress.as_ref().and_then(spv_progress_token); let description = if step.is_some() { SPV_SYNCING_DESCRIPTION @@ -1833,8 +1844,10 @@ impl App for AppState { // Blocking progress overlay: above banners, below the secret prompt. // It consumes Esc/Tab/Enter while active, so it must render before the // secret prompt (which is focus-raised and stays interactive above it) - // and before `handle_banner_esc` so the overlay wins Esc. - ProgressOverlay::render_global(ctx); + // and before `handle_banner_esc` so the overlay wins Esc. The + // secret-prompt flag (mirroring the `claim_overlay_input` gate) tells the + // block to suppress its focus management so the prompt keeps the keyboard. + ProgressOverlay::render_global(ctx, self.active_secret_prompt.is_some()); // Render any just-in-time passphrase prompt on top of the screen. self.render_secret_prompt(ctx); diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index 7125c2348..c272d30fb 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -163,16 +163,12 @@ struct OverlayState { /// Set once focus has been placed on the first button (focus trap). focus_requested: bool, /// Opt-in action id designated as the single keyboard-reachable escape (QA-002 - /// refinement). When set, `claim_input` lets Enter/Space through to its focused - /// button while still stripping every other key; every other hard block stays - /// fully keyboard-blocked. `None` by default — a block is keyboard-blocked - /// unless it opts in. + /// refinement). When set, `claim_input` activates it at frame start: a press of + /// Enter/Space enqueues this action directly (the same queue a click feeds) and + /// is stripped like every other key, so the escape needs no focus and the key + /// never reaches a widget beneath. `None` by default — a block is fully + /// keyboard-blocked unless it opts in. keyboard_escape_action: Option, - /// The egui id of the rendered escape button, recorded by `render_buttons` each - /// frame the block paints. `claim_input` reads it the following frame to confirm - /// focus is pinned to the escape before allowing Enter/Space through, so the - /// passthrough can never reach a widget beneath. - escape_focus_id: Option, } impl OverlayState { @@ -193,7 +189,6 @@ impl OverlayState { watchdog_logged: false, focus_requested: false, keyboard_escape_action: config.keyboard_escape_action.clone(), - escape_focus_id: None, } } } @@ -712,14 +707,12 @@ impl ProgressOverlay { /// /// A hard block is never keyboard-dismissable or keyboard-activatable, with one /// opt-in exception: a block that designates a single keyboard escape via - /// [`OverlayConfig::with_keyboard_escape`] keeps **Enter and Space** so its - /// focused escape button can be activated (egui fires a fake primary click on - /// either for the focused widget). Even then this only happens once the escape - /// button is *confirmed* to hold focus (its id was recorded by `render_buttons` - /// the previous frame and still matches the focused widget) — so Enter/Space can - /// never reach a widget beneath; every other key, and every non-opted block, - /// stays fully blocked. The buttoned case re-grants its own button's navigation - /// via the focus-lock filter in `render_buttons`. + /// [`OverlayConfig::with_keyboard_escape`]. For such a block, a frame-start press + /// of **Enter or Space** enqueues the designated action directly — the same queue + /// a click feeds — and the key is then stripped along with every other one. The + /// activation happens here, before the beneath `ui()` runs, so it needs no focus + /// (SEC-001) and the key never survives to a widget beneath (SEC-002). Every + /// other key, and every non-opted block, stays fully blocked. pub fn claim_input(ctx: &egui::Context) { let stack = get_overlay_state(ctx); let Some(top) = stack.last() else { @@ -734,14 +727,15 @@ impl ProgressOverlay { if top.buttons.is_empty() { ctx.memory_mut(|m| m.stop_text_input()); } - // Let Enter/Space through ONLY while a designated escape button is confirmed - // to hold focus. `escape_focus_id` is recorded by last frame's render and the - // focus check rejects the raise frame (id not yet recorded) and any frame - // where focus has drifted — so the passthrough can never reach a beneath - // widget. Everything else is stripped exactly as for a plain hard block. - let escape_confirmed = top.keyboard_escape_action.is_some() - && top.escape_focus_id.is_some() - && ctx.memory(|m| m.focused()) == top.escape_focus_id; + // A designated keyboard escape is activated HERE, at frame start: a press of + // Enter/Space enqueues its action directly (the same queue a click feeds) and + // is then stripped like every other key. Doing it before the beneath `ui()` + // runs means the activation needs no focus (SEC-001) and the key never + // survives to a focus-independent handler beneath (SEC-002). A non-opted block + // enqueues nothing and strips Enter/Space exactly the same. + let escape_action = top.keyboard_escape_action.clone(); + let key = top.key; + let mut activate_escape = false; ctx.input_mut(|i| { i.events.retain(|e| { if matches!( @@ -753,25 +747,24 @@ impl ProgressOverlay { ) { return false; } - if escape_confirmed - && matches!( - e, - egui::Event::Key { - key: egui::Key::Enter | egui::Key::Space, - pressed: true, - .. - } - ) + if let egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, + pressed: true, + repeat, + .. + } = e { - return true; + // Enqueue once per real press (ignore key-repeat); always strip. + if escape_action.is_some() && !*repeat { + activate_escape = true; + } + return false; } !matches!( e, egui::Event::Key { key: egui::Key::Tab - | egui::Key::Enter | egui::Key::Escape - | egui::Key::Space | egui::Key::ArrowUp | egui::Key::ArrowDown | egui::Key::ArrowLeft @@ -788,6 +781,11 @@ impl ProgressOverlay { ) }); }); + // Enqueue after releasing the input lock — `push_overlay_action` takes the + // `ctx.data` lock, which must not nest inside `ctx.input_mut`. + if activate_escape && let Some(action_id) = escape_action { + push_overlay_action(ctx, key, &action_id); + } // TODO(SEC-002-pointer): claim pointer press/click/drag at frame start // (analogue of the keyboard QA-001 frame-start claim) to close the // one-frame click-through on the raising frame. @@ -796,7 +794,18 @@ impl ProgressOverlay { /// Render the topmost entry. Call once per frame from `AppState::update`, /// after the panels and before the secret prompt. Early-outs to a single /// `ctx.data` read when no overlay is active (NFR-6). - pub fn render_global(ctx: &egui::Context) { + /// + /// `secret_prompt_active` mirrors the [`claim_input`](Self::claim_input) + /// secret-prompt gate: when `true` the block suppresses its own focus management + /// so the passphrase modal rendered above it keeps the keyboard (SEC-001). + /// + /// Unlike [`MessageBanner`](super::message_banner::MessageBanner), whose global + /// path pairs `set_global` with [`show_global`](super::message_banner::MessageBanner::show_global) + /// (rendered lazily inside `island_central_panel`), the overlay pairs + /// [`set_global`](Self::set_global) with `render_global`: it owns a full-window + /// dim, input sink, and focus trap that must be painted every frame from the app + /// loop on `Order::Foreground`, not lazily from within a panel. + pub fn render_global(ctx: &egui::Context, secret_prompt_active: bool) { let mut stack = get_overlay_state(ctx); let Some(top) = stack.last_mut() else { return; @@ -812,8 +821,9 @@ impl ProgressOverlay { let stuck = stuck_reveal(elapsed); let show_elapsed = top.show_elapsed || stuck; let key = top.key; - // Logs once on show / once per content change, and bumps `last_progress_at` - // on a content change so the no-progress watchdog measures true stalls. + // Logs once on show / once per shown content change, and resets the + // no-progress watchdog clock on real progress — a shown (description, step) + // change OR a hidden `progress_token` advance (see `log_overlay_state`). log_overlay_state(top); // No-progress watchdog (A-1): once the topmost request has shown no @@ -867,6 +877,7 @@ impl ProgressOverlay { stuck, watchdog, true, + secret_prompt_active, ); }); @@ -914,6 +925,7 @@ impl Component for ProgressOverlay { stuck, watchdog, false, + false, ); if let Some(action_id) = &clicked { self.last_action = Some(action_id.clone()); @@ -1025,6 +1037,7 @@ fn render_card( stuck: bool, watchdog: bool, trap_focus: bool, + secret_prompt_active: bool, ) -> Option { let mut clicked = None; egui::Frame::new() @@ -1104,7 +1117,8 @@ fn render_card( if !state.buttons.is_empty() { ui.add_space(Spacing::MD); - clicked = render_buttons(ui, state, dark_mode, trap_focus); + clicked = + render_buttons(ui, state, dark_mode, trap_focus, secret_prompt_active); } }); }); @@ -1119,22 +1133,24 @@ fn render_card( /// /// `trap_focus` is `true` only for the global full-window block: a button is /// focused on raise and a focus-lock filter traps Tab/arrows/Esc on it so keyboard -/// navigation cannot escape to a widget beneath the block. The focused button is -/// the **designated keyboard escape** when the block opts into one (QA-002 -/// refinement) — so Enter/Space, which `claim_input` lets through only for an -/// opted-in block, activate the escape and nothing else — otherwise the first -/// button. The instance [`Component`] path passes `false` (QA-003) so an inline, +/// navigation cannot escape to a widget beneath the block. The focused button is the +/// **designated keyboard escape** when the block opts into one (QA-002 refinement), +/// otherwise the first button — but the focus is purely visual: keyboard activation +/// of the escape happens at frame start in [`ProgressOverlay::claim_input`], not via +/// this focused button. `secret_prompt_active` suppresses all focus management so a +/// passphrase modal rendered above the block keeps the keyboard (SEC-001). The +/// instance [`Component`] path passes `false` for both (QA-003) so an inline, /// non-blocking widget never seizes the host screen's focus. fn render_buttons( ui: &mut egui::Ui, state: &mut OverlayState, dark_mode: bool, trap_focus: bool, + secret_prompt_active: bool, ) -> Option { - let escape_action = state.keyboard_escape_action.clone(); - // Re-request focus every frame for an opt-in escape so the escape can never be - // left un-focused (which would silently disable the keyboard exit); a non-escape - // block requests once and relies on the lock, as before. + let escape_action = state.keyboard_escape_action.as_deref(); + // Re-request focus every frame for an opt-in escape so a click/Tab can never + // leave it un-focused; a non-escape block requests once and relies on the lock. let want_focus = trap_focus && (!state.focus_requested || escape_action.is_some()); let mut clicked = None; let mut first_id = None; @@ -1162,7 +1178,7 @@ fn render_buttons( if first_id.is_none() { first_id = Some(response.id); } - if escape_action.as_deref() == Some(button.action_id.as_str()) { + if escape_action == Some(button.action_id.as_str()) { escape_id = Some(response.id); } if response.clicked() { @@ -1171,9 +1187,15 @@ fn render_buttons( } }); - // Pin focus to the designated escape if present, else the first button. + // Pin focus to the designated escape if present, else the first button — but + // never while a secret prompt is up: the prompt is rendered above the overlay and + // owns the keyboard (SEC-001), and keyboard activation of the escape no longer + // needs focus (it fires at frame start in `claim_input`). let focus_target = escape_id.or(first_id); - if trap_focus && let Some(id) = focus_target { + if trap_focus + && !secret_prompt_active + && let Some(id) = focus_target + { if want_focus { ui.memory_mut(|m| m.request_focus(id)); } @@ -1195,10 +1217,6 @@ fn render_buttons( }); state.focus_requested = true; } - // Record the escape button's id so the next frame's `claim_input` can confirm - // focus is pinned to it before letting Enter/Space through. `None` (no opt-in or - // instance path) keeps the passthrough closed. - state.escape_focus_id = escape_id; clicked } @@ -1296,7 +1314,7 @@ mod tests { /// (log-once, focus) can be inspected without a kittest harness. fn render_once(ctx: &egui::Context) { let _ = ctx.run(egui::RawInput::default(), |ctx| { - ProgressOverlay::render_global(ctx); + ProgressOverlay::render_global(ctx, false); }); } @@ -1570,15 +1588,15 @@ mod tests { assert_eq!(read(plain.key).as_deref(), Some("later")); } - /// QA-002 refinement (safety gate) — even with an escape designated, - /// `claim_input` STILL strips Enter/Space until the escape button is confirmed - /// to hold focus. Here no render has run, so `escape_focus_id` is unset and the - /// passthrough must stay closed — the keyboard exit can never reach a beneath - /// widget on the raise frame. + /// SEC-001/SEC-002 — a designated keyboard escape is activated at FRAME START: + /// `claim_input` enqueues its action (focus-independent — no render has run, so + /// no button is focused) and STRIPS Enter/Space so the key can never reach the + /// focused button or a focus-independent handler beneath. The activation does not + /// depend on, or wait for, the escape button holding focus. #[test] - fn claim_input_strips_enter_space_until_escape_focus_confirmed() { + fn claim_input_escape_block_enqueues_action_and_strips_keys() { let ctx = egui::Context::default(); - ProgressOverlay::set_global( + let handle = ProgressOverlay::set_global( &ctx, "Syncing.", OverlayConfig::new() @@ -1607,7 +1625,12 @@ mod tests { }); assert!( !leaked.get(), - "Enter/Space must stay stripped until the escape button is confirmed focused" + "Enter/Space are stripped at frame start — never reach the button or a widget beneath" + ); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "the escape action is enqueued directly at frame start, focus-independent" ); } diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 72da081e7..750634a51 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -49,7 +49,7 @@ fn overlay_harness() -> Harness<'static> { .with_size(egui::vec2(420.0, 360.0)) .build_ui(|ui| { ProgressOverlay::claim_input(ui.ctx()); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }) } @@ -371,7 +371,7 @@ fn tc_ovl_021_long_description_within_bounds() { let mut harness = Harness::builder() .with_size(egui::vec2(300.0, 400.0)) .build_ui(|ui| { - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let _handle = ProgressOverlay::set_global(&harness.ctx, long, OverlayConfig::default()); harness.step(); @@ -529,7 +529,7 @@ fn tc_ovl_028_pointer_click_beneath_blocked() { if ui.button("Increment").clicked() { counter_ui.set(counter_ui.get() + 1); } - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let handle = ProgressOverlay::set_global_spinner_only(&harness.ctx); harness.step(); @@ -556,7 +556,7 @@ fn tc_ovl_029_keyboard_beneath_blocked() { ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let _handle = ProgressOverlay::set_global( &harness.ctx, @@ -708,7 +708,7 @@ fn tc_ovl_041_tab_focus_trap() { ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let _handle = ProgressOverlay::set_global( &harness.ctx, @@ -876,7 +876,7 @@ fn tc_ovl_053_designated_escape_is_focus_pinned() { ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let handle = ProgressOverlay::set_global( &harness.ctx, @@ -932,6 +932,50 @@ fn tc_ovl_053_designated_escape_is_focus_pinned() { ); } +/// SEC-002 — a focus-INDEPENDENT global key handler beneath an escape block (the +/// pattern in `info_popup` / `selection_dialog` / `address_input`, which call +/// `i.key_pressed(Enter)` with no focus guard) NEVER observes the Enter: `claim_input` +/// strips it at frame start, before the beneath `ui()` runs, and routes it to the +/// escape's action queue instead. This is the hard-block invariant the old design +/// could not hold — under it, a confirmed-focus escape KEPT Enter/Space in `i.events`, +/// so a focus-independent handler beneath saw the key that same frame. +#[test] +fn sec002_escape_block_strips_enter_from_focus_independent_handler_beneath() { + let fired = Rc::new(Cell::new(false)); + let fired_ui = Rc::clone(&fired); + let mut harness = Harness::builder() + .with_size(egui::vec2(420.0, 360.0)) + .build_ui(move |ui| { + // Mirrors AppState::update: claim input at frame start, BEFORE the screen. + ProgressOverlay::claim_input(ui.ctx()); + // A focus-independent handler beneath the block (info_popup.rs:156 et al.). + if ui.ctx().input(|i| i.key_pressed(egui::Key::Enter)) { + fired_ui.set(true); + } + ProgressOverlay::render_global(ui.ctx(), false); + }); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + harness.step(); + harness.step(); + harness.step(); + + harness.key_press(egui::Key::Enter); + harness.step(); + assert!( + !fired.get(), + "the Enter is stripped at frame start; no focus-independent handler beneath observes it" + ); + // The Enter activated the escape instead — enqueued once at frame start, not via + // a fake button click (the key never reached the button either). + assert_eq!(handle.take_actions(), vec!["spv:escape".to_string()]); +} + // ── Group M — Non-Functional ─────────────────────────────────────────────── /// TC-OVL-046 — switching theme mid-overlay re-renders without panic. @@ -962,7 +1006,7 @@ fn tc_ovl_048_secret_prompt_renders_above_overlay() { let mut harness = Harness::builder() .with_size(egui::vec2(640.0, 480.0)) .build_ui(|ui| { - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); let config = PassphraseModalConfig { window_title: "Unlock to continue", body: "Enter your passphrase to continue.", @@ -1162,7 +1206,7 @@ fn qa_buttonless_overlay_blocks_typing_into_focused_field_beneath() { ProgressOverlay::claim_input(ui.ctx()); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); // Focus the field beneath, before any overlay exists. @@ -1222,7 +1266,7 @@ fn qa_buttonless_overlay_strips_edit_and_clipboard_events() { survived_ui.set(leaked); let mut buffer = text_ui.borrow_mut(); ui.text_edit_singleline(&mut *buffer); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); // Focus the field and type real content while idle (claim_input is a no-op @@ -1290,7 +1334,7 @@ fn reconciliation_render_global_keeps_keyboard_for_prompt() { .with_size(egui::vec2(420.0, 360.0)) .build_ui(move |ui| { // No claim_input — mirrors AppState gating it off while a prompt is up. - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); let survived = ui.ctx().input(|i| { i.events.iter().any(|e| { matches!( @@ -1424,6 +1468,82 @@ fn rq1_appstate_secret_prompt_gate_keeps_prompt_typeable_over_overlay() { }); } +/// SEC-001 (security) — drives the REAL `AppState::update` loop with BOTH a passphrase +/// prompt active AND a `with_keyboard_escape` block beneath it (the SPV-sync pattern). +/// The escape must NOT steal focus from the prompt: the prompt stays focused across +/// several frames, a typed passphrase + Enter SUBMITS (closing the prompt), and the +/// escape action is NEVER enqueued. Under the pre-fix code `render_buttons` re-requested +/// the escape's focus every frame — and ran (`render_global`) BEFORE `render_secret_prompt` +/// — so the escape stole the prompt's focus: keystrokes hit the focused button and Enter +/// fired the escape instead of submitting. The submit + empty escape queue both detect +/// that regression. +#[cfg(feature = "testing")] +#[test] +fn sec001_keyboard_escape_block_does_not_steal_focus_from_secret_prompt() { + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { + let mut app = dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) + .expect("Failed to create AppState") + .with_animations(false); + // A secret prompt is active (renders above the overlay, needs keyboard). + app.test_set_secret_prompt_active(true); + app + }); + harness.set_size(egui::vec2(800.0, 600.0)); + + // Raise a keyboard-escape block (the unbounded SPV-sync pattern) beneath the + // prompt, holding its handle so we can inspect whether the escape ever fired. + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action( + "Continue in the background", + dash_evo_tool::app::SPV_CONTINUE_BACKGROUND_ACTION, + ) + .with_keyboard_escape(dash_evo_tool::app::SPV_CONTINUE_BACKGROUND_ACTION), + ); + harness.run_steps(5); + + // The prompt renders above the escape block and KEEPS keyboard focus across + // multiple frames — the escape never re-grabs it while a prompt is up. + for _ in 0..3 { + harness.step(); + assert!( + harness.query_by_label_contains("Test prompt").is_some(), + "the secret prompt stays up above the escape block" + ); + assert!( + harness.ctx.memory(|m| m.focused()).is_some(), + "focus is held (by the prompt) every frame, not surrendered to nothing" + ); + } + + // The prompt ACCEPTS typed text and submits on Enter — proving IT held focus, + // not the escape button. + harness + .input_mut() + .events + .push(egui::Event::Text("pw".to_string())); + harness.run_steps(2); + harness.key_press(egui::Key::Enter); + harness.run_steps(5); + assert!( + harness.query_by_label_contains("Test prompt").is_none(), + "the prompt accepted the passphrase and submitted on Enter (the escape did \ + not steal focus, so the keystrokes reached the field)" + ); + // ...and the escape action was NEVER enqueued — Enter went to the passphrase. + assert!( + handle.take_actions().is_empty(), + "SEC-001: Enter must submit the passphrase, never activate a focus-stolen escape" + ); + }); +} + // ── Task 9: SPV-sync blocking overlay (startup + Connect) ──────────────────── /// Task 9 / F-SPV-A — the SPV-sync block is SCOPED to a user-initiated (armed) @@ -1443,7 +1563,7 @@ fn task9_spv_overlay_armed_scope_disarm_and_escape() { let mut harness = Harness::builder() .with_size(egui::vec2(640.0, 480.0)) .build_ui(|ui| { - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) .expect("Failed to create AppState"); @@ -1557,7 +1677,7 @@ fn task9_spv_escape_is_keyboard_activatable() { .with_size(egui::vec2(640.0, 480.0)) .build_ui(|ui| { ProgressOverlay::claim_input(ui.ctx()); - ProgressOverlay::render_global(ui.ctx()); + ProgressOverlay::render_global(ui.ctx(), false); }); let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) .expect("Failed to create AppState"); From 4ad295056ad49b0015362257d8413222927562ce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:14:37 +0200 Subject: [PATCH 20/22] docs(overlay): document hidden progress_token watchdog reset; pin cross-phase token invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RUST-003: strengthen `spv_progress_token_advances_with_height_and_is_monotonic` with a cross-phase assertion — a later phase (masternodes, step 2) at height 0 must out-rank an earlier phase (headers, step 1) near the u32 ceiling, pinning the high-bits-dominate invariant the test name claims. - DOC-001: design-addendum §1 now documents the hidden progress_token watchdog reset — `last_progress_at` resets on a shown (description, step) change OR a token advance; the token is never rendered and its reset is decoupled from the once-per-content-change log (NFR-5). Corrected the now-wrong "only when content changes" instructions and the test note, and the superseded confirmed-focus escape description. - DOC-002: dev-plan §3 superseded block — dropped `with_action` from the "there is no ..." list (it is the real shipped builder), resolving the self-contradiction. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../03-dev-plan.md | 2 +- .../04-design-addendum.md | 46 ++++++++++++------- src/context/connection_status.rs | 30 +++++++++++- 3 files changed, 59 insertions(+), 19 deletions(-) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md index 03b3e7299..2cdbc3352 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -193,7 +193,7 @@ top stays untestable until T7; note it as such. > **Superseded — see the code and the addendum.** The signature block below is the *original* > Cancel-era plan, kept for history. The shipped surface differs: there is **no** `with_cancel`, -> `with_action`, `OVERLAY_CANCEL_ACTION_ID`, or `CANCEL_LABEL`, and `is_primary` is not a public +> `OVERLAY_CANCEL_ACTION_ID`, or `CANCEL_LABEL`, and `is_primary` is not a public > field. The real builders are `with_action(label, id)` and `with_secondary_action(label, id)` > (on `OverlayConfig`, `OverlayHandle`, and the instance form), backed by a private > `ButtonStyle { Primary, Secondary }`. Clicks are delivered **keyed** to the owner via diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md index ed23f1fb0..77dec2963 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -70,9 +70,14 @@ unblocks the UI while an unsafe operation is still running. Three layers: - **Watchdog, 120 s with no progress** (`STUCK_OVERLAY_WATCHDOG_THRESHOLD`, new): the reassurance line escalates to *"This is taking much longer than expected. The operation is still running — please keep the app open."* "No progress" is measured from a new - `last_progress_at: Instant`, bumped whenever the description or step actually changes — so a - legitimately-advancing multi-step flow (J-1, four ~minute steps) **never** trips it, while a - genuinely wedged single step does. + `last_progress_at: Instant`, reset on real progress — either a shown `(description, step)` + change **or** an advance of the hidden `progress_token` (a liveness signal the owner feeds from + an advancing underlying operation, e.g. a climbing SPV height while the shown "Step N of 5" + stays constant for minutes). The `progress_token` is **never rendered**, and its reset is + intentionally decoupled from the once-per-shown-content-change log (NFR-5): a per-frame token + advance resets the clock but emits no log. So a legitimately-advancing flow — multi-step (J-1, + four ~minute steps) **or** a single slow-but-advancing phase — **never** trips it, while a + genuinely wedged step does. 3. **Developer watchdog (the leak detector for C1/C2 violations):** when the watchdog threshold is crossed, fire a **one-shot** `tracing::error!` (guarded by a `watchdog_logged` flag, logged @@ -101,19 +106,23 @@ currently `#[ignore]`). Fix: add `ProgressOverlay::claim_input(ctx)`, called **n buttons (post-T7), `claim_input` still strips text and beneath-navigation, and the overlay's own button area re-grants only its buttons' navigation via the existing `set_focus_lock_filter`. - - **QA-002 refinement — one opt-in keyboard escape.** A hard block is never keyboard-activatable + - **QA-002 refinement — one opt-in keyboard escape (mechanism superseded by SEC-001/SEC-002 — + `progress_overlay.rs` is the source of truth).** A hard block is never keyboard-activatable (Enter/Space stripped every frame), so a focused button cannot be triggered by keyboard. The one exception is a block that opts in via `OverlayConfig::with_keyboard_escape(action_id)`: it designates a single action as a keyboard-reachable escape, for **unbounded** blocks that would - otherwise strand a keyboard-only / assistive-tech user. `claim_input` then keeps Enter/Space — - but **only while that escape button is confirmed to hold focus** (its egui id was recorded by - the previous frame's `render_buttons` and still matches the focused widget). Because the escape - is focus-pinned (re-requested every frame + locked), Enter/Space can never reach a widget - beneath; on the raise frame, where focus is not yet confirmed, they are still stripped. The + otherwise strand a keyboard-only / assistive-tech user. `claim_input` activates it **at frame + start, before the beneath `ui()` runs**: a press of Enter/Space enqueues the designated action + directly (the same queue a click feeds), and the key is then stripped like every other one. + The activation needs no focus (SEC-001 — the earlier "keep Enter/Space only while the escape is + *confirmed focused*" scheme re-requested the button's focus every frame and ran before the + secret-prompt render, stealing focus from a passphrase modal above the block, so it was + removed) and the key never survives to a widget beneath, focus-dependent or not (SEC-002). + Focus on the escape is now purely visual and is suppressed while a secret prompt is up. The reference adopter is the unbounded SPV-sync block (`update_spv_overlay`), whose "Continue in the background" escape is so designated. Every OTHER hard block stays fully keyboard-blocked - (`TC-OVL-044` is the guard that the general rule is intact; `TC-OVL-051/052/053` cover the - opt-in escape). + (`TC-OVL-044` guards the general rule; `TC-OVL-051/052/053` cover the opt-in escape; + `sec001_*` / `sec002_*` cover the focus-steal and beneath-leak fixes). ### Rationale @@ -150,10 +159,11 @@ currently `#[ignore]`). Fix: add `ProgressOverlay::claim_input(ctx)`, called **n const STUCK_OVERLAY_WATCHDOG_THRESHOLD: Duration = Duration::from_secs(120); ``` - `OverlayState`: add `last_progress_at: Instant` (init `Instant::now()` in `OverlayState::new`) - and `watchdog_logged: bool` (init `false`). In `OverlayHandle::mutate` (and the instance - builders that change content), set `last_progress_at = Instant::now()` **only when content - actually changes** — reuse the content-change detection already in `log_overlay_state` - (compare `(description, step)`); bump `last_progress_at` on the same edge that logs an update. + and `watchdog_logged: bool` (init `false`), plus a hidden `progress_token: Option` and its + `last_progress_token` shadow. In `log_overlay_state`, reset `last_progress_at = Instant::now()` + when **either** the shown `(description, step)` changes **or** the `progress_token` advances — but + emit the content-update log **only** on a shown change (a token advance is a hidden liveness + signal, not a user-visible update — NFR-5). The token is never rendered. - New helpers, mirroring `stuck_reveal`: ```rust fn watchdog_tripped(last_progress: Instant) -> bool { @@ -208,8 +218,10 @@ to make — flagging it rather than burying it. pass once `claim_input` lands. This is the acceptance test for "the block is genuinely total." - TC-OVL-047 (informational portion) stays green; **add** an inline unit test `watchdog_tripped_only_past_threshold` mirroring `stuck_reveal_triggers_only_past_threshold`. -- New inline test: a content update via `set_step`/`set_description` resets `last_progress_at` - (the watchdog does not fire on a progressing flow). +- New inline tests: a shown content update via `set_step`/`set_description` resets + `last_progress_at`; and a hidden `progress_token` advance ALSO resets it (without emitting a + content-update log), while an unchanged token leaves the clock alone so a true stall still trips + the watchdog. - New inline test: `watchdog_logged` flips once and stays set (no per-frame log spam — NFR-5). - kittest: button-less overlay with an injected long elapse renders `STUCK_WATCHDOG_REASSURANCE`, not the soft line, and still exposes no dismiss control. diff --git a/src/context/connection_status.rs b/src/context/connection_status.rs index 1648be6dc..26882d055 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -722,7 +722,7 @@ impl Default for ConnectionStatus { #[cfg(test)] mod tests { use super::*; - use dash_sdk::dash_spv::sync::BlockHeadersProgress; + use dash_sdk::dash_spv::sync::{BlockHeadersProgress, MasternodesProgress}; use std::time::Duration; /// A `SyncProgress` mid-headers-download: 5000 / 10000 (50%). @@ -794,6 +794,34 @@ mod tests { // The step lives in the high 32 bits, so a later phase always out-ranks an // earlier one regardless of height — the token is monotonic across phases. assert_eq!(t1 >> 32, 1, "headers maps to step 1 in the high bits"); + + // Cross-phase, high-bits-dominate: a LATER phase at the LOWEST height must + // out-rank an EARLIER phase at a near-maximal height. Headers (step 1) near + // the top of the u32 range still loses to masternodes (step 2) at height 0, + // because the step in the high 32 bits dominates the height in the low bits — + // the exact invariant this test's name claims. + let mut headers_high = BlockHeadersProgress::default(); + headers_high.set_state(SyncState::Syncing); + headers_high.update_target_height(4_000_000_000); + headers_high.update_tip_height(4_000_000_000); + let mut early_high = SpvSyncProgress::default(); + early_high.update_headers(headers_high); + let early_high_token = spv_progress_token(&early_high).expect("syncing → token"); + assert_eq!(early_high_token >> 32, 1, "headers is step 1"); + + let mut masternodes_low = MasternodesProgress::default(); + masternodes_low.set_state(SyncState::Syncing); + // current_height stays at its default 0 — the lowest possible. + let mut late_low = SpvSyncProgress::default(); + late_low.update_masternodes(masternodes_low); + let late_low_token = spv_progress_token(&late_low).expect("syncing → token"); + assert_eq!(late_low_token >> 32, 2, "masternodes is step 2"); + + assert!( + late_low_token > early_high_token, + "a later phase at height 0 out-ranks an earlier phase near the u32 ceiling \ + — the high-bit step dominates the low-bit height" + ); } #[test] From 1f45f519dbe7793beaa47509119d5caa87b6b9ee Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:11:19 +0200 Subject: [PATCH 21/22] fix(overlay): keep escape button mouse-clickable after a backdrop press MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocking ProgressOverlay rendered its dim/pointer sink and its content card as peer Order::Foreground areas. egui auto-raises any interactable Area to the top of its Order on a pointer press (area.rs bring-to-front), so a single click on the dim backdrop floated the full-window sink above the card and permanently buried its buttons beneath the click-absorbing sink. For the unbounded SPV-sync block that meant the "Continue in the background" escape became unclickable with the mouse — force-quit was the only exit. Pin the card as a sublayer of the sink (ctx.set_sublayer): egui places a sublayer directly above its parent after the per-frame order sort, so the card-above-sink z-order now holds by construction, immune to the bring-to- front race. The sink still blocks every widget beneath, and the secret-prompt window still wins above the overlay. Add TC-OVL-054: press the backdrop, then click the escape at its own position and assert the action enqueues. It fails before this change (the sink eats the click) and passes after. Existing button-click tests never press the backdrop first, so they missed this path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../02-test-spec.md | 25 ++++++++++ src/ui/components/progress_overlay.rs | 10 ++++ tests/kittest/progress_overlay.rs | 49 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md index c64da53ee..f8e9fd0e6 100644 --- a/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -1001,6 +1001,31 @@ escape. See AC-3a, AC-3c. --- +#### TC-OVL-054 — Escape button stays mouse-clickable after a backdrop press +**Type:** kittest +**Traceability:** FR-8 (AC-3a), R-1 + +**Preconditions:** Overlay active with a secondary action `"Continue in the background"` designated +as the keyboard escape; settle frames so the centered card has cached its size. + +**Steps:** +1. Press the dim backdrop (a corner well outside the card), then release. +2. Click the escape button at its own position. +3. Assert `take_actions` returns the escape id. + +**Expected outcome:** The escape button receives the mouse click and enqueues its action even after a +prior backdrop press. The full-window pointer sink must keep blocking widgets beneath, yet must never +float above the card it dims — the card and its buttons always sit above the sink. + +**Rationale:** Regression guard. egui auto-raises any interactable `Area` to the top of its `Order` +on a pointer press (`area.rs` bring-to-front). With the sink and card as peer `Order::Foreground` +areas, a backdrop press raised the sink above the card, permanently trapping the escape beneath it — +the unbounded SPV block then had no mouse exit. Pinning the card as a sublayer of the sink fixes the +z-order by construction. Existing button-click tests (TC-OVL-024/025) never press the backdrop first, +so they missed this. + +--- + ### Group M — Non-Functional --- diff --git a/src/ui/components/progress_overlay.rs b/src/ui/components/progress_overlay.rs index c272d30fb..c4e3dbc59 100644 --- a/src/ui/components/progress_overlay.rs +++ b/src/ui/components/progress_overlay.rs @@ -863,6 +863,8 @@ impl ProgressOverlay { ui.allocate_response(rect.size(), egui::Sense::click_and_drag()); }); + let card_layer = + egui::LayerId::new(egui::Order::Foreground, egui::Id::new(OVERLAY_CARD_ID)); let mut clicked = None; egui::Area::new(egui::Id::new(OVERLAY_CARD_ID)) .order(egui::Order::Foreground) @@ -881,6 +883,14 @@ impl ProgressOverlay { ); }); + // Pin the card directly above the sink. egui auto-raises any interactable + // Area to the top of its Order on a pointer press (`area.rs` bring-to-front), + // so a backdrop press over the sink would otherwise float it above the card + // and bury the buttons beneath the click-absorbing sink — trapping the SPV + // escape. A sublayer is placed above its parent after that sort each frame, + // making the card-above-sink z-order hold by construction. + ctx.set_sublayer(sink_layer, card_layer); + // The click does not lower the overlay — the owning screen drains its own // ids via `OverlayHandle::take_actions`; the app loop only sweeps orphans. if let Some(action_id) = clicked { diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index 750634a51..d8dfc51a7 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -976,6 +976,55 @@ fn sec002_escape_block_strips_enter_from_focus_independent_handler_beneath() { assert_eq!(handle.take_actions(), vec!["spv:escape".to_string()]); } +/// TC-OVL-054 — the escape button stays mouse-clickable after a backdrop press. +/// +/// Regression guard for the SPV trap: egui auto-raises any interactable `Area` to +/// the top of its `Order` on a pointer press (`area.rs` bring-to-front). When the +/// dim/pointer sink and the card were peer `Order::Foreground` areas, pressing the +/// backdrop raised the sink ABOVE the card, permanently burying the escape button +/// beneath the click-absorbing sink — the unbounded SPV block then had no mouse +/// exit. A real mouse click on the button after such a press must still reach it +/// and enqueue the escape action. TC-OVL-024/025 never press the backdrop first, +/// so they passed while this path was broken. +#[test] +fn tc_ovl_054_escape_clickable_after_backdrop_press() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Syncing with the Dash network.", + OverlayConfig::new() + .with_secondary_action("Continue in the background", "spv:escape") + .with_keyboard_escape("spv:escape"), + ); + // Settle the centered card (anchored CENTER_CENTER moves for a couple of frames + // until its size is cached) so the button lands where we click. + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some() + ); + + // Press the dim backdrop, well outside the card. This is what trapped the + // button: the sink area auto-raises to the top of Foreground on the press. + let backdrop = egui::pos2(8.0, 8.0); + harness.drag_at(backdrop); + harness.drop_at(backdrop); + harness.step(); + + // A real mouse click at the escape button's own position must still reach it. + harness.get_by_label("Continue in the background").click(); + harness.step(); + assert_eq!( + handle.take_actions(), + vec!["spv:escape".to_string()], + "the escape button must receive the mouse click even after a backdrop press" + ); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + // ── Group M — Non-Functional ─────────────────────────────────────────────── /// TC-OVL-046 — switching theme mid-overlay re-renders without panic. From 8fefb7e47bf641b65714b259a2d52dc48e846bba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:33:26 +0200 Subject: [PATCH 22/22] fix(overlay): arm the SPV-sync block on the post-onboarding auto-start path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blocking SPV-sync overlay only shows for an *armed* episode (spv_block_step returns Idle when !armed). Two production paths armed it — boot auto-start (via the constructor: spv_block_armed = boot_auto_start_spv) and the Connect button (AppAction::StartSpv) — but the third did not: AppAction::OnboardingComplete calls try_auto_start_spv(), which spawned ensure_wallet_backend_and_start_spv WITHOUT setting spv_block_armed. So a fresh user who enabled auto-start and then finished onboarding (onboarding_completed was false at boot, so boot_auto_start_spv was false and the flag stayed false) would sync with no blocking overlay at all — exactly the journey the overlay exists to cover. Fix: arm the block inside try_auto_start_spv when the start actually fires (spv_block_armed = true; spv_overlay_dismissed = false), mirroring AppAction::StartSpv. This is the single correct arming point for that caller — the method takes &mut self now, and the active context is cloned up front so the mutation does not alias the borrow. Boot auto-start is untouched (it arms via the constructor and inlines its own start). Test: fspv_a_onboarding_auto_start_arms_spv_block drives the REAL try_auto_start_spv via a testing-only seam and asserts both the armed flag flips and that an armed Connecting sync then raises the overlay. Verified the test fails when the arm is removed and passes with it. Verified: cargo clippy --all-features --all-targets -D warnings (0 warnings), cargo test --test kittest (146 ok). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app.rs | 29 +++++++++++++--- tests/kittest/progress_overlay.rs | 55 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) diff --git a/src/app.rs b/src/app.rs index a4736c616..a2b79f859 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1404,6 +1404,20 @@ impl AppState { self.update_spv_overlay(ctx, &app_context); } + /// Test seam (F-SPV-A): run the REAL post-onboarding auto-start path + /// ([`Self::try_auto_start_spv`], the method `AppAction::OnboardingComplete` + /// invokes) so a kittest can lock that it arms the SPV-sync block. + #[cfg(feature = "testing")] + pub fn test_run_auto_start_spv(&mut self) { + self.try_auto_start_spv(); + } + + /// Test seam (F-SPV-A): observe the SPV-sync block's armed flag. + #[cfg(feature = "testing")] + pub fn test_spv_block_armed(&self) -> bool { + self.spv_block_armed + } + /// Sweep orphaned overlay action ids whose owning overlay is gone. Screens own /// dispatch and cancellation today — they drain their own clicks via /// [`OverlayHandle::take_actions`]; this loop only reclaims orphans so they @@ -1470,11 +1484,18 @@ impl AppState { /// Wires the wallet backend first (via the async chokepoint) so the start /// cannot race ahead of backend wiring. Used after onboarding completes; /// boot-time auto-start is handled inline by the eager wallet-backend init. - fn try_auto_start_spv(&self) { - let ctx = self.current_app_context(); + /// + /// Arms the SPV-sync block (F-SPV-A) when the start actually fires — this is + /// a user-initiated sync just like the Connect button, so the blocking + /// overlay must cover it. Boot auto-start arms via the constructor instead. + fn try_auto_start_spv(&mut self) { + let ctx = self.current_app_context().clone(); let auto_start = ctx.get_app_settings().auto_start_spv; - if auto_start && FeatureGate::SpvBackend.is_available(ctx) { - let ctx = ctx.clone(); + if auto_start && FeatureGate::SpvBackend.is_available(&ctx) { + // Fresh user-initiated episode: arm the block and re-arm the escape, + // mirroring AppAction::StartSpv. + self.spv_block_armed = true; + self.spv_overlay_dismissed = false; let sender = self.task_result_sender.clone(); self.subtasks.spawn_sync("spv_auto_start", async move { if let Err(e) = ctx.ensure_wallet_backend_and_start_spv(sender).await { diff --git a/tests/kittest/progress_overlay.rs b/tests/kittest/progress_overlay.rs index d8dfc51a7..7773737d9 100644 --- a/tests/kittest/progress_overlay.rs +++ b/tests/kittest/progress_overlay.rs @@ -1708,6 +1708,61 @@ fn task9_spv_overlay_armed_scope_disarm_and_escape() { }); } +/// F-SPV-A regression — completing onboarding with auto-start enabled must ARM the +/// SPV-sync block, exactly like the boot auto-start and the Connect button. Drives +/// the REAL post-onboarding path (`AppState::try_auto_start_spv`, the method +/// `AppAction::OnboardingComplete` invokes) and asserts both the armed flag flips +/// and that an armed Connecting sync then hard-blocks. Without the arm, a fresh +/// user who opted into auto-start during onboarding would sync with no overlay. +#[cfg(feature = "testing")] +#[test] +fn fspv_a_onboarding_auto_start_arms_spv_block() { + use dash_evo_tool::context::connection_status::OverallConnectionState; + crate::support::with_isolated_data_dir(|| { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + + let harness = Harness::builder() + .with_size(egui::vec2(640.0, 480.0)) + .build_ui(|ui| { + ProgressOverlay::render_global(ui.ctx(), false); + }); + let mut app = dash_evo_tool::app::AppState::new(harness.ctx.clone()) + .expect("Failed to create AppState"); + let app_context = app.current_app_context().clone(); + + // Fresh boot before onboarding completes: the block is NOT armed (boot + // auto-start only arms when onboarding was ALREADY done at startup). + assert!( + !app.test_spv_block_armed(), + "the SPV block must not be armed before onboarding completes" + ); + + // The user opted into auto-start, then finishes onboarding → the + // OnboardingComplete handler runs the real auto-start path. + app_context + .update_auto_start_spv(true) + .expect("persist auto_start_spv"); + app.test_run_auto_start_spv(); + + // The post-onboarding auto-start is user-initiated → it must arm the block. + assert!( + app.test_spv_block_armed(), + "completing onboarding with auto-start enabled must ARM the SPV-sync block" + ); + + // And an armed Connecting sync then hard-blocks (the overlay actually shows). + app_context + .connection_status() + .set_overall_state(OverallConnectionState::Connecting); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + ProgressOverlay::has_global(&harness.ctx), + "the armed post-onboarding sync raises the blocking overlay" + ); + }); +} + /// Task 9 / QA-002 refinement — the REAL SPV block's "Continue in the background" /// escape is keyboard-activatable: pressing **Enter** while it holds focus enqueues /// its action, which the driver drains to lower the block. Guards the app.rs wiring