# Peach Vault Integrity Spec ## Import · Export · Settings · Canary · Duress *For implementation by Opus. Read every section before writing a single line of code.* --- ## 0. Context and Stakes Peach is a paid, zero-knowledge password manager. Every person who uses it has trusted it with credentials that cannot be recovered if lost. The consequences of silent data loss in the import/export pipeline are catastrophic and irreversible. This spec fixes confirmed bugs, fills gaps in the data pipeline, redesigns the Settings page into a coherent product surface, and moves canary configuration to a per-entry settings drawer. Every change here is either a bug fix or a direct user-facing reliability improvement. **Do not skip items. Do not defer items to a later pass. Ship this as one coherent version bump.** --- ## 1. Confirmed Bugs — Fix These First ### 1.1 SolidJS Early-Return Anti-Pattern (Critical — Blocks All CJK Export/Import) **Files:** `src/popup/views/Export.tsx`, `src/popup/views/Import.tsx` In SolidJS, component functions run **once** at setup time, not on every state change. Using an `if` statement with a signal at the top of a function body to conditionally return JSX is broken — the condition is only evaluated at mount time and never again. **Current broken code in `ExportView`:** ```tsx // This runs ONCE at mount (when showCjkExport() === false). Never re-runs. if (showCjkExport()) { return setShowCjkExport(false)} />; } ``` **Current broken code in `ImportView`:** ```tsx // Same anti-pattern — showCjkImport() is always false at mount if (showCjkImport()) { return ; } ``` **Fix:** Remove the early-return `if` blocks entirely. Use `` inside the returned JSX to conditionally render one view or the other: ```tsx // ExportView — correct SolidJS pattern return ( }> setShowCjkExport(false)} /> ); // ImportView — correct SolidJS pattern return ( }> setShowCjkImport(false)} onDone={async () => { setShowCjkImport(false); await props.onImport(); }} /> ); ``` Extract the existing main-screen JSX into an inner component or a `fallback={}` prop so the structure stays clean. --- ### 1.2 Toggle CSS Double-Moves the Knob (Broken Visual State) **File:** `src/popup/styles/popup.css` The toggle has **two** competing layout rules for the `is-on` state applied simultaneously: ```css .peach-toggle.is-on { justify-content: flex-end; /* moves knob via flexbox */ } .peach-toggle.is-on .peach-toggle-knob { transform: translateX(12px); /* ALSO moves knob via transform */ } ``` Both rules fire at once. The knob is positioned to the right by `justify-content: flex-end` AND then pushed another 12px right by the transform — resulting in the knob visibly escaping the toggle container or sitting at an incorrect position. **Fix:** Choose **one** approach. The transform is correct and self-contained. Remove `justify-content: flex-end` from `.peach-toggle.is-on`: ```css .peach-toggle { display: flex; align-items: center; justify-content: flex-start; /* knob sits at left by default */ padding: 2px; } .peach-toggle.is-on { background: rgba(217, 104, 48, 0.22); border-color: rgba(217, 104, 48, 0.4); /* No justify-content change — knob position handled by transform only */ } .peach-toggle.is-on .peach-toggle-knob { background: var(--peach-accent); transform: translateX(12px); } ``` --- ### 1.3 Notes Lost on Peach JSON Round-Trip (Data Loss Bug) **File:** `src/shared/backup.ts`, function `normalizeImportedEntry` The current code unconditionally requires a `password` field and returns `null` for anything without one. This silently drops **all note entries** (`type: "note"`) from the imported data. A user who exports their vault and re-imports it loses every note they have. **Current broken code:** ```ts const password = typeof candidate.password === "string" ? candidate.password : ""; if (!password) return null; // ← drops notes ``` **Fix:** Add a `type === "note"` branch **before** the password check: ```ts function normalizeImportedEntry(value: unknown): ImportableEntry | null { if (!value || typeof value !== "object") return null; const candidate = value as Partial & { name?: string; note?: string; email?: string }; // Handle passkeys if (candidate.type === "passkey") { /* ... existing passkey branch ... */ } // Handle notes — no password required if (candidate.type === "note") { const title = firstNonEmptyString(candidate.title, candidate.name, "Untitled"); const notes = firstNonEmptyString(candidate.notes, candidate.note) || undefined; if (!title) return null; return { type: "note", title, notes, } as ImportableEntry; } // Handle logins — password required const password = typeof candidate.password === "string" ? candidate.password : ""; if (!password) return null; const title = firstNonEmptyString(candidate.title, candidate.name, candidate.url, candidate.username, candidate.email, "Untitled"); return { type: "login", title, username: firstNonEmptyString(candidate.username, candidate.email, ""), password, url: firstNonEmptyString(candidate.url, ""), notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined, totp: firstNonEmptyString(candidate.totp) || undefined, breach: typeof candidate.breach === "number" && candidate.breach > 0 ? candidate.breach : undefined, canary: candidate.canary ?? undefined, tags: Array.isArray(candidate.tags) ? candidate.tags : undefined, }; } ``` Also update the CSV/Bitwarden/Proton parsers to explicitly set `type: "login"` on every entry they produce, so the `type` field is always explicit rather than relying on implicit defaults. --- ### 1.4 Import Entry-by-Entry Is Slow and Error-Prone **File:** `src/popup/views/Import.tsx`, `handleImport()` The current import loop sends one `ADD_ENTRY` message per entry. A 200-entry vault does 200 round-trips to the service worker, which is slow, and a single message failure (transient service worker timeout) can leave the vault partially imported with no indication of which entries made it. **Fix:** Introduce a `BATCH_ADD_ENTRIES` message handler in `messages.ts` + `vault.ts` that accepts an array of entry drafts and adds them transactionally in a single lock-hold. Fall back to sequential if the batch message is unavailable. ```ts // messages.ts case "BATCH_ADD_ENTRIES": { const { entries } = msg.payload as { entries: any[] }; const added: VaultEntry[] = []; for (const draft of entries) { added.push(await vault.addEntry(draft)); } return { ok: true, data: added }; } // Import.tsx const batchRes = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: currentPreview.entries } }); const imported = (batchRes as { ok: boolean; data?: VaultEntry[] }).data?.length ?? 0; ``` Also add `BATCH_ADD_ENTRIES` to the `MessageType` union in `shared/types.ts`. --- ### 1.5 Encrypted Backup "Unlock Backup" Requires Separate Button Click **File:** `src/popup/views/Import.tsx` When a user selects a `peach-json` encrypted backup, they must: (1) type their password, (2) click "Unlock backup", (3) wait, (4) click "Import". Steps 1–2 can be merged. **Fix:** Auto-trigger decryption on password field blur or when the user presses Enter in the backup password input: ```tsx setBackupPassword(e.currentTarget.value)} onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }} onKeyDown={(e) => { if (e.key === "Enter") void handleUnlockBackup(); }} /> ``` Keep the explicit "Unlock backup" button as a fallback, but change its label to "Decrypt →" to clarify intent. --- ### 1.6 Import: Unknown Format Shows Empty State with No Explanation **File:** `src/popup/views/Import.tsx` When `detectImportFormat` returns `"unknown"`, the view shows 0 entries and no message to the user explaining that the format was not recognised. This looks like a silent crash. **Fix:** When `format === "unknown"`, set an error state that reads: > "We couldn't recognise this file format. Peach supports: Peach JSON backups, Bitwarden JSON/CSV, Proton Pass JSON/CSV, 1Password JSON, and generic browser CSV exports." --- ## 2. Export Flow Hardening ### 2.1 Encrypted JSON Export — Pre-flight Checks Before calling `exportEntriesToPeachJson`, validate: - Vault must have at least 1 non-deleted entry. Show error if empty: *"Your vault has no entries to export."* - Export password must be at least 12 characters. Show inline feedback if shorter. - Confirm the export password with a second input to prevent typos that lock users out of their own backup. **Updated Export UI for encrypted JSON section:** ``` [export password input] [confirm password input] strength indicator (reuse peach-strength-hairline) [warning: minimum 12 characters] [Download encrypted backup] ← disabled until both fields match + length ok ``` ### 2.2 Post-Export Confirmation After any successful export, show a non-dismissable success strip: ``` ✓ Exported [N] entries. Filename: peach-backup-2026-04-21.json Store this file and your export password somewhere safe — neither can be recovered. ``` Do not auto-dismiss this. The user should acknowledge it. ### 2.3 CSV Export — Tags, Notes, Type The current CSV export drops `tags`, `notes`, and `type` for note entries. While CSV is limited, at minimum: - Include a `notes` column (already present, verify it's populated from both `e.notes` and note-type entries). - Include a `type` column with value `"login"` or `"note"`. - Note entries: title → `name` column, content → `notes` column, other columns blank. - Exclude deleted entries (`e.deletedAt !== undefined`). Currently only `!e.deletedAt` is checked — verify this is falsy-correct (0 is valid `deletedAt` — use `e.deletedAt !== undefined`). ### 2.4 CJK Export — No Changes to Crypto Pipeline The CJK crypto pipeline (`cjkExport.ts`, `cjkPassphrase.ts`, etc.) is correct and must not be altered. The only change is the SolidJS render bug in §1.1. --- ## 3. Import Flow Hardening ### 3.1 Merge vs. Replace Decision When importing into an **existing unlocked vault**, present a clear choice before committing: ``` [ ] Add to existing vault ← append new entries alongside existing ones (default) [ ] Replace vault ← wipe all current entries, replace with import ``` Show the count: *"This will add 47 entries to your existing 12."* or *"This will replace your 12 entries with 47 imported ones."* The replace path must: 1. Show a danger confirmation step: *"Replace forever? This cannot be undone."* 2. Delete all current entries (send `DELETE_ENTRY` for each, or add a `CLEAR_VAULT_ENTRIES` background message). 3. Import the new entries. 4. Reload the entry list in App.tsx. ### 3.2 Import Progress Feedback Large vaults (100+ entries) can take 3–5 seconds with sequential adds. Show progress: ``` Importing... [37 / 200] ``` If using `BATCH_ADD_ENTRIES` (§1.4), show a spinner with entry count on completion. ### 3.3 Duplicate Detection After import, if any entries have the same `title + url + username` combination as an existing entry, surface a warning (not an error): > *"3 entries appear to be duplicates of existing ones. They were still imported — you can review and delete them in your vault."* ### 3.4 Import Source Robustness **LastPass CSV** — Add LastPass column aliases to `parseCSV`: - `"url"` or `"url"` → already covered - `"grouping"` → map to tags - `"extra"` → notes column (already in list) **1Password**: The current `parse1PasswordJSON` expects the exact `accounts[0].vaults[*].items` shape. 1Password 8 exports use a slightly different structure — add `data?.items` as a top-level array fallback in `has1PasswordShape` and `parse1PasswordJSON`. --- ## 4. Settings Page Redesign ### 4.1 Visual Hierarchy The current Settings page feels cobbled together because sections have inconsistent spacing, the duress form uses unstyled inputs, and security-critical sections have no visual weight. The redesign groups settings into four clear sections with consistent chrome: ``` ┌─ settings ──────────────────────────────────────┐ │ │ │ APPEARANCE │ │ ● Theme [dark / light] │ │ ● Accent color [swatch row] │ │ │ │ SECURITY │ │ ● Lock on close [toggle] │ │ ● Auto-lock after [select: 1m/5m/15m/30m/never] │ │ ● Breach monitoring [toggle] │ │ ○ Check all passwords now [run] │ │ └─ Last result: X passwords found in breaches │ │ ● Duress password [on/off + set up button] │ │ │ │ PASSKEYS │ │ ● Intercept passkey requests [toggle] │ │ └─ Status / browser info │ │ │ │ VAULT │ │ ○ Entries 47 │ │ ○ Import passwords [open →] │ │ ○ Export backup [open →] │ │ ○ Vault health [open →] │ │ ○ Replace vault from backup [CJK] [file] │ │ │ │ SYNC (Pro only gate) │ │ ● Status / devices / networks │ │ │ │ LICENSE │ │ ○ Enter key / show plan │ │ │ │ Peach 1.4.20 · Browser extension │ └──────────────────────────────────────────────────┘ ``` ### 4.2 Section Visual Treatment Each settings group uses consistent chrome: ```css .peach-settings-group { display: grid; gap: 0; border-bottom: 1px solid var(--peach-t3); padding-bottom: 14px; margin-bottom: 14px; } .peach-settings-group:last-child { border-bottom: none; margin-bottom: 0; } .peach-settings-label { font-family: Inter, system-ui, sans-serif; text-transform: uppercase; letter-spacing: 0.2em; font-size: 8px; color: var(--peach-t2); margin-bottom: 10px; } .peach-settings-row { display: flex; justify-content: space-between; align-items: center; min-height: 36px; gap: 12px; font-size: 13px; color: var(--peach-t1); padding: 2px 0; } .peach-settings-row--meta { min-height: 28px; font-size: 11px; color: var(--peach-t2); } .peach-settings-row--top { align-items: flex-start; padding-top: 4px; } .peach-settings-row-main { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; } ``` ### 4.3 Auto-Lock Setting Add `autoLockMinutes` to the VaultSettings shape (already defined, just not exposed in UI). Add a row under "Lock on close": ```tsx
Auto-lock after
``` Wire `applyAutoLock` → `UPDATE_SETTINGS { autoLockMinutes }` → in `vault.ts` `updateSettings` → persist. In the alarms handler, read `settings.autoLockMinutes` (default: 5) to set the idle detection interval. ```css .peach-settings-select { font-family: "IBM Plex Mono", monospace; font-size: 11px; background: transparent; border: 1px solid var(--peach-t3); border-radius: 4px; color: var(--peach-t1); padding: 3px 6px; cursor: pointer; outline: none; } .peach-settings-select:focus { border-color: var(--peach-accent); } ``` ### 4.4 Duress Password Section (Polish) The duress form already works functionally. Visual polish: 1. Wrap the form in `.peach-duress-form` with consistent `gap: 12px` between fields. 2. Use `.peach-settings-input` (not `.peach-underline-input`) for the password inputs — consistent with the rest of the settings page. 3. The status indicator (on/off) should be a styled pill, not a div with inline styles: ```css .peach-duress-status-pill { font-family: "IBM Plex Mono", monospace; font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; padding: 2px 8px; border-radius: 12px; background: var(--peach-t3); color: var(--peach-t2); flex-shrink: 0; } .peach-duress-status-pill.is-active { background: rgba(217, 104, 48, 0.15); color: var(--peach-accent); } ``` 4. When duress is enabled, show a brief reminder on what it does rather than hiding it. The "Change / disable" button collapses to a single expand toggle. 5. The `handleDisableDuress` path currently only asks for the main password. After disabling, clear all duress form fields and show a 4-second success strip, then reset. ### 4.5 Remove "Last sync: local only" Hardcoded Text In the Vault section: ```tsx
Last sync local only {/* ← hardcoded, confusing */}
``` Remove this row. The sync section already shows the actual sync status. Two contradictory "last sync" indicators are worse than one. ### 4.6 Consistent Button Sizing All action buttons in settings should use `peach-btn--subtle peach-btn--sm` as the base. The theme toggle currently uses `peach-btn--subtle` (correct) but displays `"dark"` or `"light"` as the label — it should say `"Switch to light"` / `"Switch to dark"` to be unambiguous. --- ## 5. Per-Entry Canary Settings The canary honeypot feature should be configured from the **Entry detail view**, inside a collapsed advanced section — not in the Add form (already removed) and not as a top-level settings item. ### 5.1 Location In `Entry.tsx`, after the notes section, before the delete row, add a collapsed "Advanced" disclosure: ```tsx {/* ── Advanced (canary) ──────────────────────────────── */}
``` ### 5.2 CanarySection Component A self-contained sub-component that shows: 1. **Toggle row**: "Canary alert" + toggle. When toggled on, reveals the webhook URL input. 2. **Webhook URL input** (inline edit style — same `.peach-inline-input` pattern): `https://hooks.zapier.com/...` 3. **Label input** (optional): human-readable name for this canary. 4. **"Send test ping"** button: fires `FIRE_CANARY_PING` with `trigger: "test"`, shows "sent ✓" or "failed — [reason]". 5. **Ping history**: if `entry.canary.lastPinged` is set, show *"Last accessed: [relative time]"* and *"Total alerts: [pingCount]"*. ```tsx function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise }) { const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary)); const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? ""); const [label, setLabel] = createSignal(props.entry.canary?.label ?? ""); const [saving, setSaving] = createSignal(false); const [testResult, setTestResult] = createSignal(null); const [testing, setTesting] = createSignal(false); async function save() { setSaving(true); const canary = enabled() && webhookUrl().trim() ? { webhookUrl: webhookUrl().trim(), label: label().trim() || undefined, lastPinged: props.entry.canary?.lastPinged, pingCount: props.entry.canary?.pingCount } : undefined; await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: { canary } } }); setSaving(false); await props.onSaved(); } async function testPing() { const url = webhookUrl().trim(); if (!url) return; setTesting(true); setTestResult(null); const res = await sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" }, }) as { ok: boolean; error?: string }; setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`); setTesting(false); } return (
canary alert
setWebhookUrl(e.currentTarget.value)} onBlur={() => void save()} /> setLabel(e.currentTarget.value)} onBlur={() => void save()} />
{testResult()}

Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()} {" · "}{props.entry.canary?.pingCount ?? 0} total alerts

); } ``` ### 5.3 Canary CSS Additions ```css .peach-entry-advanced { margin-top: 12px; border-top: 1px solid var(--peach-t3); padding-top: 10px; position: relative; z-index: 1; } .peach-entry-advanced-toggle { display: flex; align-items: center; justify-content: space-between; width: 100%; background: transparent; border: none; cursor: pointer; padding: 0; color: var(--peach-t2); } .peach-canary-section { margin-top: 10px; display: grid; gap: 6px; } ``` ### 5.4 Canary Pings Still Fire Silently When a user **copies a password or username** from Entry.tsx that has `entry.canary`, Peach should fire the webhook silently. Wire this in `copyField()`: ```tsx async function copyField(text: string, label: string, button?: HTMLButtonElement) { const response = await sendMessage({ type: "COPY", payload: { text, label } }); if ((response as { ok: boolean }).ok) { // ... existing copy feedback ... // Canary: fire silently on copy if this entry has a webhook const entry = props.entry; if (entry?.canary?.webhookUrl) { const trigger = label === "password" ? "password_copied" : "username_copied"; void sendMessage({ type: "FIRE_CANARY_PING", payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger }, }); } } } ``` --- ## 6. CJK Phrase Import — QR Scan Path Currently, the QR code is only generated for export (show-as-QR button). The import side has no QR scanner. This is out of scope for this spec (requires camera access in extension popup). Document this as a known limitation: > *QR scan import requires camera access in the extension popup, which Chromium does not support. Use paste-from-clipboard instead. The QR code is useful for viewing on a trusted secondary screen and typing the phrase manually.* The current QR display path on export is correct as-is. No changes needed. --- ## 7. Entry Detail — Canary Badge (Remove from Header, Keep in Advanced) Currently (after v1.4.20) `Entry.tsx` has no canary badge visible. That's correct. The advanced section in §5.1 is the only entry point. Do not add a badge back to the entry header. Canary is a hidden feature. --- ## 8. `shared/types.ts` — MessageType Union Update Add `BATCH_ADD_ENTRIES` to the `MessageType` union so TypeScript doesn't warn on the new message: ```ts export type MessageType = | "CREATE_VAULT" | "UNLOCK" | "LOCK" | "IS_UNLOCKED" | "HAS_VAULT" | "GET_ENTRIES" | "ADD_ENTRY" | "BATCH_ADD_ENTRIES" // ← add this | "UPDATE_ENTRY" | "DELETE_ENTRY" // ... rest of union unchanged ``` --- ## 9. File Download in Extension Context The `downloadBlob` helper in `Export.tsx` uses `URL.createObjectURL` + anchor click. This works correctly in Chrome extension popups under MV3. **Do not replace it with `chrome.downloads.download()`** — that API requires the `downloads` permission and creates an additional permission prompt that would reduce install conversion. The current approach is correct. If the download silently fails (which can happen if the service worker is sleeping when the popup opens), add a `.catch()` guard and surface the error: ```ts function downloadBlob(content: string, mimeType: string, filename: string): void { try { const url = URL.createObjectURL(new Blob([content], { type: mimeType })); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); // Required in some browsers — ensure it's in the DOM a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { console.error("[Peach] Download failed:", err); throw err; // Caller should catch and surface to UI } } ``` Note: `a.click()` without `document.body.appendChild(a)` can fail in some Firefox/Chromium extension contexts. Adding the append/remove step is a safe guard. --- ## 10. CSS Classes Still Missing Check that all of these exist in `popup.css` before declaring the settings page done. Add any that are absent: | Class | Purpose | |---|---| | `.peach-settings-input` | Full-width underlined input used in Export/import password fields | | `.peach-settings-select` | Dropdown select element (§4.3) | | `.peach-duress-status-pill` / `.is-active` | Duress on/off pill (§4.4) | | `.peach-entry-advanced` | Wrapper for the advanced disclosure in Entry | | `.peach-entry-advanced-toggle` | Button row that expands advanced section | | `.peach-canary-section` | Canary sub-form inside advanced | | `.peach-restore-warning` | Red warning box in Import | | `.peach-provider-chips` / `.peach-provider-chip` | Supported format chips in Import | | `.peach-import-summary` | "Found N entries, M skipped" text | | `.peach-import-preview` / `.peach-import-preview-row` | 8-row preview table | Run `npx tsc --noEmit` and check browser devtools for CSS missing-class warnings after implementation. --- ## 11. Implementation Order Implement in this exact order. Each step should compile cleanly before proceeding. 1. **Fix toggle CSS** (§1.2) — no logic changes, safe 2. **Fix SolidJS early-return bugs** (§1.1) — enables CJK export/import to work 3. **Fix notes data loss** (§1.3) — critical data integrity fix 4. **Fix download helper** (§9) — defensive guard 5. **Add BATCH_ADD_ENTRIES** (§1.4 + §8) — background + messages + Import.tsx 6. **Add auto-unlock-on-enter for encrypted import** (§1.5) 7. **Fix unknown-format error message** (§1.6) 8. **Export pre-flight and confirmation** (§2.1, §2.2, §2.3) 9. **Import merge/replace choice** (§3.1) 10. **Settings page redesign** (§4) — visual + auto-lock wiring 11. **Per-entry canary section** (§5) 12. **Typecheck + build + version bump to 1.4.21** 13. **Smoke test** — export encrypted JSON, re-import it, verify all entries round-trip including notes --- ## 12. Smoke Test Protocol Before shipping, verify this exact flow manually: **Export round-trip:** 1. Open Peach. Confirm vault has at least: 1 login, 1 note, 1 entry with TOTP, 1 entry with tags. 2. Go to Settings → Export → Encrypted file backup. 3. Enter a 14-character password + confirm. Click "Download encrypted backup". 4. Verify file downloads. Open it in a text editor — confirm it's JSON with `"type":"peach-export"`. 5. Go to Settings → Import. Select the downloaded file. Enter the same password. Click "Decrypt →". 6. Verify preview shows correct entry count. Click Import. 7. Verify vault now contains the expected entries including the note and TOTP entry. **CJK phrase round-trip:** 1. Go to Settings → Export → CJK phrase backup. 2. Enter a 5-word diceware passphrase (use the "Generate" button). Confirm it. Click "Export →". 3. After generation, click "Copy phrase". Open a text editor and paste. 4. Go to Settings → Import → Restore from CJK phrase. 5. Paste the phrase. Verify "✓ Phrase recognised" appears. 6. Enter the same passphrase. Click "Restore". 7. Verify entries are restored. **Toggle persistence:** 1. Open Settings. Toggle "Lock on close" off. 2. Close the popup completely. Re-open. 3. Verify toggle is still off. If it reverts to on, the `UPDATE_SETTINGS` persistence is broken. **Canary config:** 1. Open an entry. Scroll to "advanced". Expand. 2. Toggle canary on. Enter a real webhook URL. Click elsewhere (blur). 3. Close popup. Re-open. Open same entry. Verify canary is still on with URL intact. 4. Click "test ping". Verify webhook receives the payload. --- ## 13. Version Bump After all changes: bump to `1.4.21` in both `manifest.json` and `manifest.firefox.json`. Update `CONTINUITY.md` with a `[DECISIONS]` entry and a `[PROGRESS]` entry covering all changes from this spec.