fix(#223): backup verify, redirect to 12 words after second wrong pick - #224
Conversation
… pick Prevents brute-forcing the seed confirmation by elimination: on the second wrong pick in a verification round, return to the 12-words screen with a message and restart verification. Counter resets per round. Tested on Linux desktop — screenshots in the PR. Refs MostroP2P#223
|
Warning Review limit reached
Next review available in: 29 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
WalkthroughThe backup verification flow counts incorrect selections within each round. After the second wrong pick, it returns to the word-reveal step and displays a localized snackbar; starting verification resets the counter. Deterministic debug word injection and widget tests cover retry and completion flows. ChangesBackup verification
Estimated code review effort: 3 (Moderate) | ~20 minutes Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/features/account/screens/backup_ritual_screen.dart`:
- Around line 174-179: Update the retry SnackBar in the backup ritual screen to
use an AppLocalizations.of(context) string instead of hardcoded text, and remove
the fixed “12” by using a localized word-count-neutral message or interpolation
based on _words.length. Add the corresponding ARB localization entry and
preserve the existing retry behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 12a7fbf3-632f-43a2-ad8f-be53015144a7
📒 Files selected for processing (1)
lib/features/account/screens/backup_ritual_screen.dart
CodeRabbit: the retry SnackBar bypassed ARB localization and hardcoded '12 words', misleading 24-word mnemonic users (the screen renders _words.length). Adds ARB key backupRitualSecondFailureMessage (count-neutral) in en/de/es/fr/it and routes the SnackBar through AppLocalizations. Verified live on Linux — message now reads 'back up your secret words'. Refs MostroP2P#223
grunch
left a comment
There was a problem hiding this comment.
Actionable comments posted: 4
Walkthrough
Adds a per-round wrong-pick counter to the backup ritual's verification step. The first wrong pick keeps the existing inline "try again" feedback; the second sends the user back to step 1 (the 12 words) through the existing _backToWords() helper and surfaces a localized SnackBar. _startVerification() resets the counter so each round starts fresh. One new l10n key across all five locales, plus regenerated AppLocalizations.
| File | Change |
|---|---|
lib/features/account/screens/backup_ritual_screen.dart |
_failCount field, second-miss redirect in _onOptionTap, reset in _startVerification |
lib/l10n/app_{en,es,fr,de,it}.arb |
backupRitualSecondFailureMessage (+ @description in the en template) |
lib/l10n/app_localizations*.dart |
regenerated |
Verification performed on the PR head (f55d31b)
| Check | Result |
|---|---|
flutter gen-l10n |
✅ no diff — the committed generated files match the ARBs |
flutter analyze lib/features/account/screens/backup_ritual_screen.dart |
✅ No issues found |
| l10n coverage | ✅ all 5 locales updated; @description correctly only in the en template |
| Terminology | ✅ "secret words" / "palabras secretas" matches secretWordsTitle, tapToViewAndSaveSecretWords, generateNewUserDialogContent |
| Control flow | ✅ _step = 1 is assigned only in _startVerification(), so _failCount is always reset before a round starts — the missing reset in _backToWords() is a latent trap, not a live bug |
Blocking
1. No test covers the new behaviour — severity: high
test/features/account/ contains only backup_reminder_provider_test.dart; there is no widget test for BackupRitualScreen, and none is added here. The repo requires tests for new functionality with an 80% floor, and #223's acceptance criteria lists "Widget tests cover the fail-twice path" explicitly.
This change is exactly the kind that regresses silently: a future refactor of _onOptionTap that drops the counter restores unlimited guessing with no visible symptom, and "tested live on Linux desktop" will not catch it.
Minimum coverage requested:
- first wrong pick → stays on step 2,
wrongPickMessagevisible; - second wrong pick → back on step 1 (words grid rendered) with the SnackBar shown;
- after restarting verification, one wrong pick is tolerated again (proves the reset);
- happy path still confirms the backup.
2. Closes #223 closes a half-implemented issue — severity: high
#223 has two parts: (a) fail-twice → re-show words, and (b) the Account "Secret Words" section. This PR implements (a) and asks about (b) in the description — so merging as-is auto-closes the issue with (b) unresolved and no longer tracked anywhere.
Please use Refs #223 here and let the issue close when (b) is settled, or split (b) into its own issue and update the checklist on #223.
Non-blocking
| Severity | Finding | Where |
|---|---|---|
| Medium | _backToWords() — the round-reset helper — does not reset _failCount |
inline |
| Medium | Code comments cite "#204 review"; #204 is a closed PR about restore-from-mnemonic | inline |
| Low | _failCount++ is mutated outside setState |
inline |
| Low | The failing pick gets no inline feedback before the redirect; the transient SnackBar is the only explanation | inline |
| Low | One miss per round (across all 3 slots), not per word — confirm this is the intended budget | inline |
| Low | Accessibility: the redirect is announced only through a SnackBar; a persistent banner on step 1 would survive the transition and be readable by assistive tech | — |
Note (not a change request)
Step 2 already has a "Show words again" button, so a determined user can re-read the words at any time. That is fine — the goal here is to force the review, not to make passing without it impossible. Worth keeping in mind when wording the code comment, which currently claims the confirmation "can't be brute-forced by elimination"; what the change actually prevents is grinding a single slot through all four options.
Nice, focused change otherwise — the fix is in the right place and reuses the existing helper rather than inventing a second path back to the words.
| /// Wrong-pick counter for the current verification round. On the 2nd wrong | ||
| /// pick the user is sent back to the words screen and verification restarts, | ||
| /// so the confirmation can't be brute-forced by elimination (#204 review). | ||
| int _failCount = 0; |
There was a problem hiding this comment.
#204 is a closed PR for a different feature (restore sessions from mnemonic). The traceable reference for this change is #223. Same applies to the inline comment on lines 170-172.
| /// Wrong-pick counter for the current verification round. On the 2nd wrong | |
| /// pick the user is sent back to the words screen and verification restarts, | |
| /// so the confirmation can't be brute-forced by elimination (#204 review). | |
| int _failCount = 0; | |
| /// Wrong-pick counter for the current verification round. On the 2nd wrong | |
| /// pick the user is sent back to the words screen and verification restarts, | |
| /// so a single slot can't be solved by tapping every option in turn (#223). | |
| int _failCount = 0; |
| } else { | ||
| setState(() => _wrongPick = word); | ||
| _failCount++; | ||
| if (_failCount >= 2) { |
There was a problem hiding this comment.
❓ Confirm the attempt budget: one miss per round, not per word.
_failCount accumulates across all three slots, so this sequence bounces the user:
- slot 1 → wrong (
_failCount = 1), then correct; - slot 2 → correct;
- slot 3 → one wrong pick (
_failCount = 2) → back to step 1.
A single mistap anywhere in a three-word challenge costs the whole round. That may well be the intent — it is what makes the elimination grind impossible — but the PR title and #223 both read as "two wrong picks", which a user would naturally take as two chances at the word currently in front of them.
Worth an explicit decision, and worth recording in the field doc either way: /// Wrong-pick counter for the current verification round is the only place these semantics live.
| }); | ||
| } else { | ||
| setState(() => _wrongPick = word); | ||
| _failCount++; |
There was a problem hiding this comment.
🧹 State mutated outside setState.
_failCount is the only state field in this class mutated outside a setState call. Harmless today because nothing in build() reads it, but it makes the field an outlier in an otherwise consistent class, and it becomes a stale-UI bug the moment anything renders "1 attempt left" or similar.
| _failCount++; | |
| setState(() => _failCount++); |
| // Second wrong pick this round: don't let the user keep guessing by | ||
| // elimination. Send them back to the 12 words to actually back them | ||
| // up, then restart verification from scratch (#204 review). | ||
| _backToWords(); |
There was a problem hiding this comment.
🛠️ _backToWords() doesn't reset _failCount — latent trap.
_backToWords() is the "abandon this verification round" helper: it clears _challenge, _filled, _activeSlot, _options and _wrongPick. After this PR it resets everything about the round except the new counter.
It is safe today — I verified that _step = 1 is assigned only inside _startVerification(), which does reset the counter — but the invariant is now split across two methods with nothing enforcing it. Any future path back into step 2 that skips _startVerification() (resume, deep link, a "retry verification" shortcut) would start at _failCount == 2 and bounce the user on their first miss, permanently.
Cheapest fix is to let the helper own the reset:
void _backToWords() {
setState(() {
_step = 0;
_challenge = const [];
_filled = [null, null, null];
_activeSlot = 0;
_options = const [];
_wrongPick = null;
_failCount = 0; // round is over — the next one starts clean
});
}Keeping the reset in _startVerification() too is fine as belt-and-braces, but one of the two should be the clear owner.
Secondary point on the same lines: _backToWords() clears _wrongPick, so the pick that triggered the redirect never gets the red highlight — the user is yanked off the screen with no inline indication of what they got wrong. Consider flagging the pick briefly before transitioning, or giving step 1 a persistent explanation instead of relying on a SnackBar that disappears after a few seconds.
Adds widget tests covering the requested cases: first wrong pick stays on step 2 with feedback; second wrong pick on the same word returns to step 1 with the SnackBar; after restart one wrong pick is tolerated (reset works); all correct confirms the backup. Review fixes: - per-word miss budget: _failCount resets on each correct pick (was per-round); field doc updated, comment no longer overclaims 'brute-force' - _backToWords() owns the counter reset (belt-and-braces with _startVerification) - _failCount++ wrapped in setState - stale MostroP2P#204 references corrected to MostroP2P#223 Adds @VisibleForTesting debugWords + debugCorrectWordForActiveSlot seams so the verification flow is widget-testable without the Rust bridge. Refs MostroP2P#223
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@lib/l10n/app_it.arb`:
- Line 206: Update the Italian value for backupRitualSecondFailureMessage to use
natural UI wording: state that the choice is still wrong, then instruct the user
to check and save their secret words before repeating verification from the
beginning.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 96a0ae44-19c0-43f3-948b-e8bc84973a88
📒 Files selected for processing (13)
lib/features/account/screens/backup_ritual_screen.dartlib/l10n/app_de.arblib/l10n/app_en.arblib/l10n/app_es.arblib/l10n/app_fr.arblib/l10n/app_it.arblib/l10n/app_localizations.dartlib/l10n/app_localizations_de.dartlib/l10n/app_localizations_en.dartlib/l10n/app_localizations_es.dartlib/l10n/app_localizations_fr.dartlib/l10n/app_localizations_it.darttest/features/account/backup_ritual_screen_test.dart
| "relayAddFailed": "Impossibile aggiungere il relay", | ||
| "relayRemoveFailed": "Impossibile rimuovere il relay", | ||
| "backupConfirmCheckbox": "Ho annotato le mie parole e le ho salvate in modo sicuro", | ||
| "backupRitualSecondFailureMessage": "Di nuovo errato. Per favore controlla e salva le tue parole segrete, poi verifica dall'inizio.", |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "Locate ARB files:"
fd -a 'app_.*\.arb$' lib/l10n 2>/dev/null | sort || true
echo
echo "Relevant entry in app_it.arb:"
if [ -f lib/l10n/app_it.arb ]; then
rg -n -C 3 '"backupRitualSecondFailureMessage"|backupRitualSecondFailureMessage' lib/l10n/app_it.arb || true
else
echo "lib/l10n/app_it.arb not found"
fi
echo
echo "Source/other locale entries for this key:"
for f in lib/l10n/*.arb; do
echo "--- $f"
rg -n -C 2 '"backupRitualSecondFailureMessage"|backupRitualSecondFailureMessage' "$f" || true
done
echo
echo "Surrounding Italian entries around line 206:"
if [ -f lib/l10n/app_it.arb ]; then
sed -n '180,225p' lib/l10n/app_it.arb | nl -ba -v180
fiRepository: MostroP2P/app
Length of output: 3288
🌐 Web query:
Italian translation "Di nuovo errato" usage natural phrasing "ancora errato" "Scelta ancora sbagliata"
💡 Result:
The phrases you asked about have slightly different nuances and levels of naturalness in Italian. Di nuovo errato (Again incorrect / Incorrect again) This phrase is grammatically correct and clearly indicates that something which was already incorrect has become incorrect again [1][2]. It implies a state that was interrupted [2]. Ancora errato (Still incorrect / Again incorrect) This phrase is also common but more ambiguous due to the multiple meanings of ancora [3][4]. - It can mean "still incorrect" (a state that has continued to be incorrect over time) [1][4]. - It can mean "again incorrect" (repetition), though in this specific context, it is often interpreted as "still wrong" [1][3]. Because of this ambiguity, if you specifically want to emphasize the repetition of an error, di nuovo errato or un'altra volta errato are often clearer [1][2]. Scelta ancora sbagliata (Still a wrong choice / Another wrong choice) This is a very natural and common phrasing [5]. Depending on the context, it can mean: - "Still a wrong choice": You made a choice, and it continues to be the wrong one [4]. - "Another wrong choice": You made a wrong choice, and now you have made another one [4]. Using "ancora" here is perfectly idiomatic and very frequently used to express that something remains wrong or that a recurring error has occurred [4]. Summary of usage: If you want to say something is incorrect again (e.g., an answer on a test), "di nuovo errato" or "ancora sbagliato" are standard [1][2]. If you are describing a choice that remains wrong or is another instance of a wrong choice, "scelta ancora sbagliata" is highly natural [5][4]. If you want to avoid all ambiguity, "un'altra volta errato/sbagliato" (wrong one more time) is an excellent, unambiguous alternative [1][2].
Citations:
- 1: https://www.youtube.com/watch?v=Q3uuFUWNVBM
- 2: https://dailyitalianwords.com/ancora-vs-di-nuovo-italian-again/
- 3: https://italian.stackexchange.com/questions/2248/ancora-vs-di-nuovo
- 4: https://italianpills.com/ancora-italian/
- 5: https://www.linguee.it/inglese-italiano/traduzione/wrong+choice.html
Use more natural Italian wording.
Di nuovo errato is grammatically acceptable but reads stiff for UI copy. A clearer wording would be La scelta è ancora sbagliata. Controlla e salva le tue parole segrete, poi ripeti la verifica dall'inizio.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@lib/l10n/app_it.arb` at line 206, Update the Italian value for
backupRitualSecondFailureMessage to use natural UI wording: state that the
choice is still wrong, then instruct the user to check and save their secret
words before repeating verification from the beginning.
|
Thanks for the thorough review all addressed in the latest commit: Blocking: Tests added test/features/account/backup_ritual_screen_test.dart with all four cases you listed: first wrong pick stays on step 2 with wrongPickMessage; second wrong pick returns to step 1 with the SnackBar; after restart one wrong pick is tolerated (proves the reset); happy path confirms the backup. All 4 pass. Needed a small @VisibleForTesting seam (debugWords + debugCorrectWordForActiveSlot) since IdentityService.getMnemonicWords() is a static Rust call the seam lets the flow run without the bridge. Non-blocking: _backToWords() now owns the counter reset. For part (b) (Account "Secret Words"), I'll open a separate issue and update the #223 checklist, unless you'd rather keep it here. |
grunch
left a comment
There was a problem hiding this comment.
Re-review of 1cd1cb2 — actionable comments posted: 3
Everything from the previous round is addressed on the code side. One blocker remains, and it's in the PR description rather than the code.
Previous round
| Finding | Status |
|---|---|
| No test covers the new behaviour | ✅ 4 widget tests added, covering exactly the requested cases |
_backToWords() doesn't reset _failCount |
✅ reset moved into the helper |
_failCount++ outside setState |
✅ wrapped |
| Comments cite "#204 review" | ✅ now #223 |
| Attempt budget ambiguous | ✅ resolved by switching to per-word (reset on each correct pick) and documenting it on the field |
Closes #223 closes a half-implemented issue |
❌ still open |
Nice call on the per-word semantics — it's the more defensible reading and the field doc now records it.
Verification performed on 1cd1cb2
| Check | Result |
|---|---|
flutter test test/features/account/backup_ritual_screen_test.dart |
✅ 4/4 pass |
| Flake check (challenge slots and decoys are randomised, unseeded) | ✅ 12 consecutive runs, 0 failures |
flutter analyze on both changed files |
✅ No issues found |
Is the debugWords seam actually enforced? |
✅ verified — see below |
On the last one: I expected @visibleForTesting on a constructor parameter (rather than on the field) to be decorative, so I checked it rather than assert it. Adding a production call site under lib/ that passes debugWords: produces
warning • The member 'debugWords' can only be used within
'package:mostro/features/account/screens/backup_ritual_screen.dart' or a test
• invalid_use_of_visible_for_testing_member
and flutter analyze exits 1, which CI runs (.github/workflows/ci.yml:130). So the seam is genuinely closed against production use — no action needed. Worth stating explicitly, since a test hook that injects seed words into the screen whose job is protecting them is the kind of thing that deserves proof rather than a reviewer's assumption.
Blocking
Closes #223 + the description no longer matches the code — severity: high
Two problems in the PR body, one of them carried over:
-
Closes #223still closes an issue whose second half (the Account "Secret Words" section) is unresolved — you raise that very question further down in the same description. Please useRefs #223. -
The description now describes code that no longer exists. It still says:
- "Added a
_failCountfield (per verification round)" — it is per word since1cd1cb2; - "
_startVerificationresets_failCountto 0, so each new round starts fresh" — the reset that matters now is the one on each correct pick, plus the one in_backToWords(); - it quotes the message as "…back up your 12 words…" — that hardcoded string was removed in
f55d31b.
Worth noting the practical consequence, since it's easy to miss: per-word budget means a user can now miss once on each of the three words and still finish the round, where before a single miss anywhere ended it. That's the right trade-off (elimination is still blocked — 2 of 4 options max per slot), but it is strictly more lenient than "second wrong pick in a verification round", which is what the description and title still promise.
- "Added a
Fix the body and this is good to merge; I'll flip to approve. My earlier CHANGES_REQUESTED stands only on this item.
Non-blocking
| Severity | Finding | Where |
|---|---|---|
| Low | _aWrongOption / find.text(...).first are unscoped — latent, not live |
inline |
| Low | @visibleForTesting on a member of a private class is a no-op |
inline |
| Low | Duplicated comment in the third test | inline |
| Low | No test drives _backToWords() through the "Show words again" button or the AppBar back arrow — the redirect path is covered, the manual path isn't, and the counter reset now lives in that helper |
— |
| Low | Still open from the last round: the pick that triggers the redirect never gets the red highlight, since _backToWords() clears _wrongPick. The user is moved off the screen with only a transient SnackBar explaining why |
— |
| return w!; | ||
| } | ||
|
|
||
| Finder _aWrongOption(WidgetTester tester) { |
There was a problem hiding this comment.
🧹 Unscoped finder — latent, not live.
_aWrongOption searches find.text(w) across the whole tree and returns .first. It is correct today for two reasons that are incidental rather than guaranteed:
- it is only ever called before a slot has been filled, and
- the fixture
_wordshappens to be 12 distinct words.
Once a slot is filled, its chip renders above the options grid, so .first would resolve to the filled chip instead of an option button. tester.tap on it silently does nothing — no exception — and the test would then assert against stale state and fail with a confusing message rather than pointing at the finder.
I ran the file 12 times to check the randomised challenge doesn't already trip this; it doesn't. Still worth scoping the finder to the option buttons (e.g. find.widgetWithText(InkWell, w) or a find.descendant under the options grid). Line 133's find.text(correct).first in the happy-path test has the same shape and is the one most likely to bite, since it runs after slots start filling.
| // Clear the SnackBar so it can't intercept the restart tap, then restart. | ||
| // The failure SnackBar sits at the bottom over the verify button; clear it | ||
| // so it can't intercept the restart tap. |
There was a problem hiding this comment.
🧹 Duplicated comment.
These three lines say the same thing twice — looks like an editing leftover.
| // Clear the SnackBar so it can't intercept the restart tap, then restart. | |
| // The failure SnackBar sits at the bottom over the verify button; clear it | |
| // so it can't intercept the restart tap. | |
| // The failure SnackBar sits at the bottom over the verify button; clear it | |
| // so it can't intercept the restart tap. |
…comment Non-blocking review: find.text().first could match a filled answer chip once slots fill; scope to the option buttons via widgetWithText(InkWell). Also removes a duplicated comment in the restart test. Refs MostroP2P#223
grunch
left a comment
There was a problem hiding this comment.
Approving — the blocker is resolved.
Refs #223 is in, and the description now matches the code: per-word counter, _backToWords() named as the reset owner, and the leniency trade-off spelled out. Part (b) of #223 stays tracked instead of being auto-closed.
Recap of what I verified on 1cd1cb2: 4/4 widget tests pass, 12 consecutive runs with no flake against the randomised challenge, flutter analyze clean on both files, and the debugWords seam confirmed closed against production use.
One factual nit for whoever reads this later, no change needed: the description says the seam "lets the flow run without the Rust bridge". It avoids calling IdentityService.getMnemonicWords(), but the test still needs generated lib/src/rust/ to compile — I hit Error when reading 'lib/src/rust/api/identity.dart' on a clean checkout until I generated it.
The three low-severity inline comments (unscoped finder, no-op @visibleForTesting on the private state member, duplicated comment) are yours to take or leave — none of them block. Good iteration on this one.





Refs #223 backup-verification UX (fail-twice -> re-show words).
Problem
On the seed-confirmation step, a wrong pick only showed "incorrect" and let the
user keep tapping the same slot. Across retries they could eliminate the decoys
and land on the correct word by process of elimination "completing" backup
without knowing the words.
Fix
A per-word wrong-pick counter (
_failCount), reset to 0 on each correct pick.A second wrong pick on the same word sends the user back to the 12-words
screen with a localized message and restarts verification. The counter is reset
in both
_backToWords()(the owner) and_startVerification()(belt-and-braces).Practical effect: a user may miss once on each of the three challenge words and
still finish the round strictly more lenient than "second wrong pick anywhere",
but elimination is still blocked (at most 2 of 4 options per slot).
Tests
test/features/account/backup_ritual_screen_test.dart(4 cases):A
@visibleForTesting debugWordsseam lets the flow run without the Rust bridge;it's enforced (a production call site fails
flutter analyze).Note on the second part of #223
#223 also covers the Account "Secret Words" section. The current screen already
shows "To restore your account", Show/Hide, the confirm checkbox, and a
"✓ Backed up" badge after backup (banner disappears). I'll split that into its
own issue and update the #223 checklist using
Refshere so #223 stays open.