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..e4df06199 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/01-requirements-ux.md @@ -0,0 +1,495 @@ +# 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`) + +--- + +> **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_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 +> 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 +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. _(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* +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. _(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. + +### 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. _(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. +**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. 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. 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 +`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 `set_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. +> +> **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." +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 | _(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. | +| 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_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** + 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). +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 `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 +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?** _(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 + 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. +``` 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..f8e9fd0e6 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md @@ -0,0 +1,1215 @@ +# 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]**. + +> **Post-outage reframe (generic button).** First-class "Cancel" was removed in favour of a +> 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 — +> numbers are preserved; each carries a "(reframed post-outage: generic button)" note. + +### 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::set_global(ctx, description, config)` | Raises the overlay; returns `OverlayHandle` | +| `ProgressOverlay::has_global(ctx)` | Returns `true` when an overlay is active | +| `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_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 | + +--- + +## 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::set_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::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`. + +**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::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. + +**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_action("Continue", "overlay.action")`. + +**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 `set_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 `set_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_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 — 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 `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:** +1. Show overlay with a generic button. +2. Run one frame; assert a button with label `"Cancel"` is present. +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 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 (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**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"`. +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 (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** FR-7 (AC-7.2) + +**Preconditions:** Overlay raised with two generic buttons (no built-in Cancel). + +**Steps:** +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. + +**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 — 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_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:** +1. Show overlay; run one frame. +2. Query the widget rects for `"First action"` and `"Second action"`. + +**Expected outcome:** +- 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). + +--- + +### 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 generic +button. + +**Steps:** +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 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 `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):** +- `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 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 the 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 `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`). + +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 generic button. A text input widget exists beneath. + +**Steps:** +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 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 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 generic button (`with_action("Cancel", "overlay.cancel")`). +There is no built-in Cancel, so Esc has nothing to trigger. + +**Steps:** +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 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 button (reframed post-outage: generic 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 a focused button (reframed post-outage: generic button) +**Type:** kittest +**Traceability:** NFR-3 (AC-3c) + +**Preconditions:** Overlay active with a single generic button that holds focus. + +**Steps:** +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 focused button's action). +- The overlay is still active. + +**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. + +--- + +#### 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 + +--- + +#### TC-OVL-045 — Log-once: state change logs once, not once per frame +**Type:** design-review + ctx.data +**Traceability:** NFR-5 + +**Invariant:** +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. + +**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::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`. +- 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, 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 | +| 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 (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) | + +--- + +## 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. | +| **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? | + +--- + +## 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.* 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..2cdbc3352 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md @@ -0,0 +1,574 @@ +# 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`) + +--- + +## 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_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/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 + 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 +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 — 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 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 + 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 + 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 / 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. + +--- + +## 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`, `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`, +`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 + +> **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`, +> `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 +> `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 + +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 `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 */ } + +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 set_global(ctx: &egui::Context, + description: impl std::fmt::Display, + config: OverlayConfig) -> 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). + 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):** `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). +`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); // sweeps ORPHAN actions only (addendum §2 A-3) +``` + +On network switch, clear the overlay alongside banners (mirror `MessageBanner::clear_all_global`). + +### 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`). + +``` + 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) + +> **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: + +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/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 + 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 + 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`; `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, +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 `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. +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** soft reveal; **+120 s no-progress watchdog** (addendum §1 A-1) | Superseded by addendum §1 | +| 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) | + +--- + +## 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. 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..77dec2963 --- /dev/null +++ b/docs/ai-design/2026-06-17-blocking-progress-overlay/04-design-addendum.md @@ -0,0 +1,367 @@ +# 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) + +> **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 +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`, 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 + 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`. + - **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` 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` 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 + +- **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`), 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 { + 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 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. +- 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/docs/user-stories.md b/docs/user-stories.md index b62f841c3..02cca9635 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,28 @@ 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 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 + +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 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 1c1379ceb..a2b79f859 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,10 @@ 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_SYNC_PHASE_COUNT, spv_phase_step, + spv_progress_token, +}; use crate::context::migration_status::{MigrationState, MigrationStep}; use crate::database::Database; #[cfg(not(feature = "testing"))] @@ -15,7 +18,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}; +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::{ @@ -57,6 +63,69 @@ 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** — 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. 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: +/// 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 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 { + /// Armed, not dismissed, still connecting/syncing: raise (or keep + update). + Block, + /// 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 (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 { + // 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 { + SpvBlockStep::Block + } + } + } +} + /// 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 @@ -176,6 +245,22 @@ 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 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* 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 /// (Detecting → SingleKey → Shielded → WalletSeeds → WalletMeta → Finalize → Success/Failed) @@ -662,6 +747,11 @@ impl AppState { welcome_screen: None, 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, cold_start_migration_dispatched: BTreeSet::new(), @@ -881,6 +971,13 @@ 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. 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.spv_overlay = None; + self.spv_block_armed = false; + self.spv_overlay_dismissed = false; for screen in self.main_screens.values_mut() { screen.change_context(app_context.clone()) @@ -928,6 +1025,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(); @@ -1017,6 +1131,127 @@ impl AppState { self.handle_backend_task(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); } + /// Drive the blocking SPV-sync overlay each frame (Task 9 — the overlay's + /// 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 + /// 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. + /// + /// 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 — 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. + 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_block_armed, self.spv_overlay_dismissed, state) { + 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 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. + // + // 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 + } else { + SPV_CONNECTING_DESCRIPTION + }; + if self.spv_overlay.is_none() { + // 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); + } + 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); + match step { + Some(n) => { + handle.set_step(n, SPV_SYNC_PHASE_COUNT); + } + None => { + handle.clear_step(); + } + } + if let Some(t) = token { + handle.set_progress_token(t); + } + } + } + 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 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(); + } + } + + // 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 + .spv_overlay + .as_ref() + .map(|handle| handle.take_actions()) + .unwrap_or_default(); + if actions + .iter() + .any(|id| id == SPV_CONTINUE_BACKGROUND_ACTION) + { + self.spv_overlay_dismissed = true; + self.spv_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 @@ -1129,6 +1364,83 @@ impl AppState { } } + /// 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); + } + + /// 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 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(); + 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 + /// 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!( + target = "ui::overlay", + action_id = %action_id, + "Overlay action received for an overlay that is no longer active — dropping" + ); + } + } + pub fn visible_screen_mut(&mut self) -> &mut Screen { if self.screen_stack.is_empty() { self.active_root_screen_mut() @@ -1172,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 { @@ -1512,6 +1831,20 @@ 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). + self.claim_overlay_input(ctx); + // Show welcome screen if onboarding not completed let mut actions = Vec::new(); if self.show_welcome_screen @@ -1522,6 +1855,14 @@ 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. 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); @@ -1532,11 +1873,16 @@ impl App for AppState { .trigger_refresh(active_context.as_ref()), ); + // 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); self.handle_banner_esc(ctx); self.drain_banner_actions(ctx); + self.drain_overlay_actions(ctx); for action in actions { match action { @@ -1589,21 +1935,23 @@ 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…" - // 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, ) @@ -1719,3 +2067,98 @@ mod migration_banner_tests { assert_eq!(MIGRATION_RETRY_ACTION_ID, "migration:retry:finish_unwire"); } } + +#[cfg(test)] +mod spv_overlay_tests { + use super::*; + + 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 armed_blocks_while_getting_connected() { + for state in [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ] { + assert_eq!(spv_block_step(true, false, state), SpvBlockStep::Block); + } + } + + /// 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 armed_dismissed_stands_down_without_disarming() { + for state in [ + OverallConnectionState::Disconnected, + OverallConnectionState::Connecting, + OverallConnectionState::Syncing, + ] { + assert_eq!(spv_block_step(true, true, state), SpvBlockStep::Stand); + } + } + + /// C1 / F-SPV-A — armed + terminal (Synced/Error) → Disarm, regardless of + /// dismissal: lower and disarm so ambient sync afterwards never re-blocks. + #[test] + fn armed_terminal_state_disarms() { + for dismissed in [false, true] { + for state in [ + OverallConnectionState::Synced, + OverallConnectionState::Error, + ] { + assert_eq!(spv_block_step(true, dismissed, state), SpvBlockStep::Disarm); + } + } + } + + /// The escape action id is stable — production raises it and + /// `update_spv_overlay` matches on it; a typo would drop the click. + #[test] + fn continue_background_action_id_is_stable() { + assert_eq!( + SPV_CONTINUE_BACKGROUND_ACTION, + "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/src/context/connection_status.rs b/src/context/connection_status.rs index 0d17560dc..2bbdc459c 100644 --- a/src/context/connection_status.rs +++ b/src/context/connection_status.rs @@ -453,6 +453,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. /// @@ -672,6 +681,71 @@ 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; + +/// 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 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" + ); + 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 @@ -689,7 +763,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::sync::Arc; use std::time::Duration; @@ -719,6 +793,79 @@ 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); + } + + /// 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"); + + // 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] 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 6fe85d7e9..690c5c961 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_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/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/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 new file mode 100644 index 000000000..c4e3dbc59 --- /dev/null +++ b/src/ui/components/progress_overlay.rs @@ -0,0 +1,1901 @@ +//! 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_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 +//! 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`) +//! +//! 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::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. +//! - **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); + +/// 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. +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 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); + +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) +} + +/// 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, 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 +/// 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, + /// 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). + 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` 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, +} + +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: 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, + keyboard_escape_action: config.keyboard_escape_action.clone(), + } + } +} + +/// 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 { + description: Option, + 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, + /// Opt-in keyboard escape (QA-002 refinement); see [`with_keyboard_escape`]. + /// + /// [`with_keyboard_escape`]: Self::with_keyboard_escape + keyboard_escape_action: Option, +} + +impl OverlayConfig { + pub fn new() -> Self { + Self::default() + } + + /// 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(); + 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 + } + + /// 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: `label` and `action_id` are + /// user-visible and logged — never pass secrets or PII. + pub fn with_action(mut self, label: impl fmt::Display, action_id: impl fmt::Display) -> Self { + self.buttons + .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_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, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Self { + self.buttons + .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 +/// [`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. +/// +/// 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, + 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 **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_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, + label: impl fmt::Display, + action_id: impl fmt::Display, + ) -> Option<&Self> { + 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)) + } + + /// 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). + /// + /// 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 + } + + /// 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. + 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); + + 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. 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)?; + 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 ([`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`]. +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 **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(action_id, label, ButtonStyle::Primary)); + } + self + } + + /// Add a **secondary** action button (left of the primary). Same generic + /// semantics as [`with_action`](Self::with_action). + pub fn with_secondary_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(action_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. + /// + /// **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 set_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. + /// + /// As a button-less block it has no escape, so the SEC-001 lifecycle rule from + /// [`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 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). + pub fn has_global(ctx: &egui::Context) -> bool { + !get_overlay_state(ctx).is_empty() + } + + /// 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); + } + orphans + } + + /// 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`, 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, with one + /// opt-in exception: a block that designates a single keyboard escape via + /// [`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 { + 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()); + } + // 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!( + e, + egui::Event::Text(_) + | egui::Event::Copy + | egui::Event::Cut + | egui::Event::Paste(_) + ) { + return false; + } + if let egui::Event::Key { + key: egui::Key::Enter | egui::Key::Space, + pressed: true, + repeat, + .. + } = e + { + // 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::Escape + | 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, + .. + } + ) + }); + }); + // 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. + } + + /// 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). + /// + /// `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; + }; + + // 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 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 + // 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" + ); + // 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; + let rect = ctx.content_rect(); + + // 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::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::Foreground) + .fixed_pos(rect.min) + .show(ctx, |ui| { + 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) + .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) + .show(ctx, |ui| { + clicked = render_card( + ui, + top, + dark_mode, + elapsed, + show_elapsed, + stuck, + watchdog, + true, + secret_prompt_active, + ); + }); + + // 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 { + push_overlay_action(ctx, key, &action_id); + } + + if show_elapsed || watchdog { + 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 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, + false, + ); + 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 the soft reassurance line (D-4). Takes `elapsed` as a +/// parameter (the clock seam) so the threshold logic is unit-testable without a +/// real wall-clock wait. +fn stuck_reveal(elapsed: Duration) -> 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 { + current >= 1 && total >= 1 && current <= total +} + +/// 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" + ); + 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, + "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. +#[allow(clippy::too_many_arguments)] +fn render_card( + ui: &mut egui::Ui, + state: &mut OverlayState, + dark_mode: bool, + elapsed: Duration, + show_elapsed: bool, + stuck: bool, + watchdog: bool, + trap_focus: bool, + secret_prompt_active: 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); + let seconds = elapsed.as_secs(); + ui.label( + egui::RichText::new(format!("Elapsed: {seconds}s")) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + // 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) + .color(DashColors::text_secondary(dark_mode)), + ); + } + + if !state.buttons.is_empty() { + ui.add_space(Spacing::MD); + clicked = + render_buttons(ui, state, dark_mode, trap_focus, secret_prompt_active); + } + }); + }); + clicked +} + +/// 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: 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), +/// 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.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; + 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. + 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 first_id.is_none() { + first_id = Some(response.id); + } + if escape_action == Some(button.action_id.as_str()) { + escape_id = Some(response.id); + } + if response.clicked() { + clicked = Some(button.action_id.clone()); + } + } + }); + + // 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 + && !secret_prompt_active + && 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 + // 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)); + } +} + +/// 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))) + .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 (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(OverlayAction { + key, + action_id: 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. 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); +} + +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::set_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, false); + }); + } + + #[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::set_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::set_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::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()); + 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::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_action("Run in background", "overlay.bg") + .is_none() + ); + assert!(!ProgressOverlay::has_global(&ctx)); + } + + #[test] + fn double_clear_is_a_noop() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_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::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()); + + 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)); + } + + /// 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 handle_take_actions_drains_own_fifo_then_empties() { + let ctx = egui::Context::default(); + let handle = ProgressOverlay::set_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::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"); + 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::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::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 + // 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, + /// 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(); + let handle = ProgressOverlay::set_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::sweep_orphan_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::set_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" + ); + } + + /// 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")); + } + + /// 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_escape_block_enqueues_action_and_strips_keys() { + 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 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 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" + ); + } + + /// `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(); + ProgressOverlay::set_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::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(); + 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_action("Run in background", "overlay.bg"); + // 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()); + } + + /// 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 (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::set_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/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/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..7773737d9 --- /dev/null +++ b/tests/kittest/progress_overlay.rs @@ -0,0 +1,1921 @@ +//! 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). `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`. +//! +//! 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::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 `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`. + +use std::cell::{Cell, RefCell}; +use std::rc::Rc; +#[cfg(feature = "testing")] +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 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(), false); + }) +} + +// ── 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::set_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::set_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::set_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::set_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::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_action("Run in background", "overlay.bg") + .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::set_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::set_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::set_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_action("Run in background", "overlay.bg"), + ]; + for config in configs { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_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::set_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::set_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. +/// 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::set_global( + &harness.ctx, + "Slow operation.", + OverlayConfig::new().with_elapsed(), + ); + harness.step(); + assert!(harness.query_by_label("Elapsed: 0s").is_some()); + + // 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: 2s").is_some(), + "the readout advanced to 2s via the clock seam" + ); + assert!( + 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" + ); +} + +// ── 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::set_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::set_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::set_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::set_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(), false); + }); + let _handle = ProgressOverlay::set_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:?}" + ); + // 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. +#[test] +fn tc_ovl_022_spinner_only_valid() { + let mut harness = overlay_harness(); + 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()); + 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::set_global(&harness.ctx, "Hard block.", OverlayConfig::default()); + harness.step(); + assert!(harness.query_by_label("Cancel").is_none()); + assert!(handle.take_actions().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::set_global( + &harness.ctx, + "Working.", + 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. + harness.step(); + harness.step(); + harness.step(); + assert!(harness.query_by_label("Cancel").is_some()); + + harness.get_by_label("Cancel").click(); + harness.step(); + // 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)); +} + +/// 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::set_global( + &harness.ctx, + "Background-able.", + OverlayConfig::new().with_action("Run in background", "overlay.run_in_bg"), + ); + 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!(handle.take_actions(), vec!["overlay.run_in_bg".to_string()]); + assert!(ProgressOverlay::has_global(&harness.ctx)); +} + +/// 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::set_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .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). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Primary").click(); + harness.step(); + harness.get_by_label("Secondary").click(); + harness.step(); + + assert_eq!( + handle.take_actions(), + vec!["primary".to_string(), "secondary".to_string()] + ); + assert!(handle.take_actions().is_empty()); +} + +/// 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_secondary_left_primary_right() { + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Two buttons.", + OverlayConfig::new() + .with_action("Primary action", "primary") + .with_secondary_action("Secondary action", "secondary"), + ); + harness.step(); + + 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!( + second_x < first_x, + "the secondary button must sit to the left of the primary action" + ); +} + +// ── 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(), false); + }); + let handle = ProgressOverlay::set_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!(handle.take_actions().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| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.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::set_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!(handle.take_actions().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::set_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::set_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::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)); + 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::set_global(&ctx, "Operation A.", OverlayConfig::default()); + let b = ProgressOverlay::set_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::set_global( + &harness.ctx, + "Operation A.", + OverlayConfig::new().with_action("Cancel", "cancel_a"), + ); + let b = ProgressOverlay::set_global( + &harness.ctx, + "Operation B.", + 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). + harness.step(); + harness.step(); + harness.step(); + + harness.get_by_label("Cancel").click(); + harness.step(); + // 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" + ); +} + +// ── 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| { + ProgressOverlay::claim_input(ui.ctx()); + let mut buffer = text_ui.borrow_mut(); + ui.text_edit_singleline(&mut *buffer); + ProgressOverlay::render_global(ui.ctx(), false); + }); + let _handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.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::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + + harness.key_press(egui::Key::Escape); + harness.step(); + assert!( + handle.take_actions().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::set_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!(handle.take_actions().is_empty()); +} + +/// 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_and_space_do_not_activate_button() { + let mut harness = overlay_harness(); + let handle = ProgressOverlay::set_global( + &harness.ctx, + "Working.", + OverlayConfig::new().with_action("Cancel", "overlay.cancel"), + ); + harness.step(); + harness.step(); + + harness.key_press(egui::Key::Enter); + harness.step(); + harness.key_press(egui::Key::Space); + harness.step(); + assert!( + handle.take_actions().is_empty(), + "neither Enter nor Space may trigger the focused button's action" + ); + 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(), 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(); + 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" + ); +} + +/// 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()]); +} + +/// 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. +#[test] +fn tc_ovl_046_theme_switch_mid_overlay() { + let mut harness = overlay_harness(); + harness.ctx.set_visuals(egui::Visuals::dark()); + let _handle = ProgressOverlay::set_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(), false); + 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::set_global(&harness.ctx, "Signing.", OverlayConfig::default()); + harness.step(); + 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" + ); + // 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) — 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 + // reassurance line — the reveal is purely time-driven and benign. + let mut harness = overlay_harness(); + let _handle = ProgressOverlay::set_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-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::set_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. +#[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_action("Run in background", "overlay.bg"), + )); + 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 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 ────────── +// +// 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), 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())); + let text_ui = Rc::clone(&text); + 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(), false); + }); + + // 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::set_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() + ); +} + +// 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(), false); + }); + + // 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::set_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 +/// 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(), false); + 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::set_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_action("Act", "inline.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)" + ); +} + +// ── 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::set_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)" + ); + }); +} + +/// 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) +/// 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_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"); + 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(), false); + }); + 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(); + let set_state = |s| app_context.connection_status().set_overall_state(s); + + // 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), + "an armed connecting sync raises the block" + ); + harness.step(); + harness.step(); + harness.step(); + assert!( + harness + .query_by_label("Continue in the background") + .is_some(), + "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 / 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 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" + ); + + // 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)); + harness.get_by_label("Continue in the background").click(); + harness.step(); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the escape lowers the block (user never trapped)" + ); + app.test_drive_spv_overlay(&harness.ctx); + assert!( + !ProgressOverlay::has_global(&harness.ctx), + "the block is not re-raised within the dismissed episode" + ); + + // 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), + "a fresh armed episode re-blocks" + ); + }); +} + +/// 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 +/// (`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(), 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(); + 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 +/// 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" + ); +}