Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
660a11a
docs(overlay): requirements + UX spec for blocking progress overlay
lklimek Jun 17, 2026
9f4efec
docs(overlay): test case specification
lklimek Jun 17, 2026
ce985e1
docs(overlay): development plan and architecture decisions
lklimek Jun 17, 2026
112c9f8
feat(overlay): generic button facility + Component trait conformance
lklimek Jun 17, 2026
a8964b2
docs(overlay): align D-5 and risk notes to the generic-button redesign
lklimek Jun 17, 2026
ba9a421
test(overlay): add ignored probe proving button-less keyboard-block g…
lklimek Jun 17, 2026
6af742a
fix(overlay): frame-start input claim (QA-001) + clear action queue o…
lklimek Jun 17, 2026
ddc4075
fix(overlay): QA-wave hardening — watchdog, keyed dispatch, secondary…
lklimek Jun 17, 2026
7840703
docs(overlay): sync requirements/dev-plan to the shipped design + add…
lklimek Jun 17, 2026
c51279e
test(overlay): close re-QA coverage residuals RQ-1/RQ-2/RQ-3
lklimek Jun 17, 2026
66d8ec0
docs(overlay): close QA residuals — README catalog entry, requirement…
lklimek Jun 17, 2026
22ff0cb
feat(overlay): hard-block the UI during startup/Connect SPV sync
lklimek Jun 17, 2026
f8851dc
refactor(overlay): align SPV-sync block to the approved spec
lklimek Jun 17, 2026
cc896ea
docs(overlay): reconcile SPV-sync block decision (F-SPV-1) + phase-st…
lklimek Jun 17, 2026
01364a9
fix(overlay): scope SPV block to user-initiated sync + de-jargon copy…
lklimek Jun 17, 2026
3f896e5
Merge branch 'docs/platform-wallet-migration-design' into feat/blocki…
lklimek Jun 17, 2026
b726871
fix(overlay): address review findings — deterministic elapsed test, S…
lklimek Jun 17, 2026
2186429
fix(overlay): close one-frame SPV block gap, fix slow-phase watchdog,…
lklimek Jun 18, 2026
20723d2
feat(overlay): keyboard-reachable escape for the SPV hard block (QA-0…
lklimek Jun 18, 2026
f50cbca
fix(overlay): activate keyboard escape at frame start (SEC-001, SEC-002)
lklimek Jun 18, 2026
4ad2950
docs(overlay): document hidden progress_token watchdog reset; pin cro…
lklimek Jun 18, 2026
d057027
Merge remote-tracking branch 'origin/docs/platform-wallet-migration-d…
lklimek Jun 18, 2026
1f45f51
fix(overlay): keep escape button mouse-clickable after a backdrop press
lklimek Jun 18, 2026
327b603
Merge origin/docs/platform-wallet-migration-design into feat/blocking…
lklimek Jun 18, 2026
8fefb7e
fix(overlay): arm the SPV-sync block on the post-onboarding auto-star…
lklimek Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

1,215 changes: 1,215 additions & 0 deletions docs/ai-design/2026-06-17-blocking-progress-overlay/02-test-spec.md

Large diffs are not rendered by default.

574 changes: 574 additions & 0 deletions docs/ai-design/2026-06-17-blocking-progress-overlay/03-dev-plan.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions docs/user-stories.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

---

Expand Down Expand Up @@ -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.
473 changes: 458 additions & 15 deletions src/app.rs

Large diffs are not rendered by default.

149 changes: 148 additions & 1 deletion src/context/connection_status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down Expand Up @@ -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<u32> {
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<u64> {
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
Expand All @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/ui/components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
4 changes: 4 additions & 0 deletions src/ui/components/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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};
5 changes: 5 additions & 0 deletions src/ui/components/passphrase_modal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading