1# Peach Vault Integrity Spec2## Import · Export · Settings · Canary · Duress34*For implementation by Opus. Read every section before writing a single line of code.*56---78## 0. Context and Stakes910Peach 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.1112This 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.1314**Do not skip items. Do not defer items to a later pass. Ship this as one coherent version bump.**1516---1718## 1. Confirmed Bugs — Fix These First1920### 1.1 SolidJS Early-Return Anti-Pattern (Critical — Blocks All CJK Export/Import)2122**Files:** `src/popup/views/Export.tsx`, `src/popup/views/Import.tsx`2324In 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.2526**Current broken code in `ExportView`:**27```tsx2829if (showCjkExport()) {30 return <CJKExportView onBack={() => setShowCjkExport(false)} />;31}32```3334**Current broken code in `ImportView`:**35```tsx3637if (showCjkImport()) {38 return <CJKImportView ... />;39}40```4142**Fix:** Remove the early-return `if` blocks entirely. Use `<Show>` inside the returned JSX to conditionally render one view or the other:4344```tsx4546return (47 <Show when={showCjkExport()} fallback={<MainExportView ... />}>48 <CJKExportView onBack={() => setShowCjkExport(false)} />49 </Show>50);515253return (54 <Show when={showCjkImport()} fallback={<MainImportView ... />}>55 <CJKImportView56 mode="existing"57 onBack={() => setShowCjkImport(false)}58 onDone={async () => { setShowCjkImport(false); await props.onImport(); }}59 />60 </Show>61);62```6364Extract the existing main-screen JSX into an inner component or a `fallback={}` prop so the structure stays clean.6566---6768### 1.2 Toggle CSS Double-Moves the Knob (Broken Visual State)6970**File:** `src/popup/styles/popup.css`7172The toggle has **two** competing layout rules for the `is-on` state applied simultaneously:7374```css75.peach-toggle.is-on {76 justify-content: flex-end; 77}78.peach-toggle.is-on .peach-toggle-knob {79 transform: translateX(12px); 80}81```8283Both 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.8485**Fix:** Choose **one** approach. The transform is correct and self-contained. Remove `justify-content: flex-end` from `.peach-toggle.is-on`:8687```css88.peach-toggle {89 display: flex;90 align-items: center;91 justify-content: flex-start; 92 padding: 2px;93}94.peach-toggle.is-on {95 background: rgba(217, 104, 48, 0.22);96 border-color: rgba(217, 104, 48, 0.4);97 98}99.peach-toggle.is-on .peach-toggle-knob {100 background: var(--peach-accent);101 transform: translateX(12px);102}103```104105---106107### 1.3 Notes Lost on Peach JSON Round-Trip (Data Loss Bug)108109**File:** `src/shared/backup.ts`, function `normalizeImportedEntry`110111The 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.112113**Current broken code:**114```ts115const password = typeof candidate.password === "string" ? candidate.password : "";116if (!password) return null; 117```118119**Fix:** Add a `type === "note"` branch **before** the password check:120121```ts122function normalizeImportedEntry(value: unknown): ImportableEntry | null {123 if (!value || typeof value !== "object") return null;124 const candidate = value as Partial<VaultEntry> & { name?: string; note?: string; email?: string };125126 127 if (candidate.type === "passkey") { }128129 130 if (candidate.type === "note") {131 const title = firstNonEmptyString(candidate.title, candidate.name, "Untitled");132 const notes = firstNonEmptyString(candidate.notes, candidate.note) || undefined;133 if (!title) return null;134 return {135 type: "note",136 title,137 notes,138 } as ImportableEntry;139 }140141 142 const password = typeof candidate.password === "string" ? candidate.password : "";143 if (!password) return null;144145 const title = firstNonEmptyString(candidate.title, candidate.name, candidate.url, candidate.username, candidate.email, "Untitled");146 return {147 type: "login",148 title,149 username: firstNonEmptyString(candidate.username, candidate.email, ""),150 password,151 url: firstNonEmptyString(candidate.url, ""),152 notes: firstNonEmptyString(candidate.notes, candidate.note) || undefined,153 totp: firstNonEmptyString(candidate.totp) || undefined,154 breach: typeof candidate.breach === "number" && candidate.breach > 0 ? candidate.breach : undefined,155 canary: candidate.canary ?? undefined,156 tags: Array.isArray(candidate.tags) ? candidate.tags : undefined,157 };158}159```160161Also 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.162163---164165### 1.4 Import Entry-by-Entry Is Slow and Error-Prone166167**File:** `src/popup/views/Import.tsx`, `handleImport()`168169The 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.170171**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.172173```ts174175case "BATCH_ADD_ENTRIES": {176 const { entries } = msg.payload as { entries: any[] };177 const added: VaultEntry[] = [];178 for (const draft of entries) {179 added.push(await vault.addEntry(draft));180 }181 return { ok: true, data: added };182}183184185const batchRes = await sendMessage({ type: "BATCH_ADD_ENTRIES", payload: { entries: currentPreview.entries } });186const imported = (batchRes as { ok: boolean; data?: VaultEntry[] }).data?.length ?? 0;187```188189Also add `BATCH_ADD_ENTRIES` to the `MessageType` union in `shared/types.ts`.190191---192193### 1.5 Encrypted Backup "Unlock Backup" Requires Separate Button Click194195**File:** `src/popup/views/Import.tsx`196197When 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.198199**Fix:** Auto-trigger decryption on password field blur or when the user presses Enter in the backup password input:200201```tsx202<input203 class="peach-restore-input"204 type="password"205 placeholder="backup password"206 value={backupPassword()}207 onInput={(e) => setBackupPassword(e.currentTarget.value)}208 onBlur={() => { if (backupPassword().trim()) void handleUnlockBackup(); }}209 onKeyDown={(e) => { if (e.key === "Enter") void handleUnlockBackup(); }}210/>211```212213Keep the explicit "Unlock backup" button as a fallback, but change its label to "Decrypt →" to clarify intent.214215---216217### 1.6 Import: Unknown Format Shows Empty State with No Explanation218219**File:** `src/popup/views/Import.tsx`220221When `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.222223**Fix:** When `format === "unknown"`, set an error state that reads:224> "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."225226---227228## 2. Export Flow Hardening229230### 2.1 Encrypted JSON Export — Pre-flight Checks231232Before calling `exportEntriesToPeachJson`, validate:233234- Vault must have at least 1 non-deleted entry. Show error if empty: *"Your vault has no entries to export."*235- Export password must be at least 12 characters. Show inline feedback if shorter.236- Confirm the export password with a second input to prevent typos that lock users out of their own backup.237238**Updated Export UI for encrypted JSON section:**239```240[export password input] 241[confirm password input] 242strength indicator (reuse peach-strength-hairline) 243[warning: minimum 12 characters] 244[Download encrypted backup] ← disabled until both fields match + length ok245```246247### 2.2 Post-Export Confirmation248249After any successful export, show a non-dismissable success strip:250251```252✓ Exported [N] entries. Filename: peach-backup-2026-04-21.json253 Store this file and your export password somewhere safe — neither can be recovered.254```255256Do not auto-dismiss this. The user should acknowledge it.257258### 2.3 CSV Export — Tags, Notes, Type259260The current CSV export drops `tags`, `notes`, and `type` for note entries. While CSV is limited, at minimum:261262- Include a `notes` column (already present, verify it's populated from both `e.notes` and note-type entries).263- Include a `type` column with value `"login"` or `"note"`.264- Note entries: title → `name` column, content → `notes` column, other columns blank.265- 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`).266267### 2.4 CJK Export — No Changes to Crypto Pipeline268269The 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.270271---272273## 3. Import Flow Hardening274275### 3.1 Merge vs. Replace Decision276277When importing into an **existing unlocked vault**, present a clear choice before committing:278279```280[ ] Add to existing vault ← append new entries alongside existing ones (default)281[ ] Replace vault ← wipe all current entries, replace with import 282```283284Show the count: *"This will add 47 entries to your existing 12."* or *"This will replace your 12 entries with 47 imported ones."*285286The replace path must:2871. Show a danger confirmation step: *"Replace forever? This cannot be undone."*2882. Delete all current entries (send `DELETE_ENTRY` for each, or add a `CLEAR_VAULT_ENTRIES` background message).2893. Import the new entries.2904. Reload the entry list in App.tsx.291292### 3.2 Import Progress Feedback293294Large vaults (100+ entries) can take 3–5 seconds with sequential adds. Show progress:295296```297Importing... [37 / 200]298```299300If using `BATCH_ADD_ENTRIES` (§1.4), show a spinner with entry count on completion.301302### 3.3 Duplicate Detection303304After import, if any entries have the same `title + url + username` combination as an existing entry, surface a warning (not an error):305306> *"3 entries appear to be duplicates of existing ones. They were still imported — you can review and delete them in your vault."*307308### 3.4 Import Source Robustness309310**LastPass CSV** — Add LastPass column aliases to `parseCSV`:311- `"url"` or `"url"` → already covered312- `"grouping"` → map to tags313- `"extra"` → notes column (already in list)314315**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`.316317---318319## 4. Settings Page Redesign320321### 4.1 Visual Hierarchy322323The 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:324325```326┌─ settings ──────────────────────────────────────┐327│ │328│ APPEARANCE │329│ ● Theme [dark / light] │330│ ● Accent color [swatch row] │331│ │332│ SECURITY │333│ ● Lock on close [toggle] │334│ ● Auto-lock after [select: 1m/5m/15m/30m/never] │335│ ● Breach monitoring [toggle] │336│ ○ Check all passwords now [run] │337│ └─ Last result: X passwords found in breaches │338│ ● Duress password [on/off + set up button] │339│ │340│ PASSKEYS │341│ ● Intercept passkey requests [toggle] │342│ └─ Status / browser info │343│ │344│ VAULT │345│ ○ Entries 47 │346│ ○ Import passwords [open →] │347│ ○ Export backup [open →] │348│ ○ Vault health [open →] │349│ ○ Replace vault from backup [CJK] [file] │350│ │351│ SYNC (Pro only gate) │352│ ● Status / devices / networks │353│ │354│ LICENSE │355│ ○ Enter key / show plan │356│ │357│ Peach 1.4.20 · Browser extension │358└──────────────────────────────────────────────────┘359```360361### 4.2 Section Visual Treatment362363Each settings group uses consistent chrome:364365```css366.peach-settings-group {367 display: grid;368 gap: 0;369 border-bottom: 1px solid var(--peach-t3);370 padding-bottom: 14px;371 margin-bottom: 14px;372}373.peach-settings-group:last-child {374 border-bottom: none;375 margin-bottom: 0;376}377378.peach-settings-label {379 font-family: Inter, system-ui, sans-serif;380 text-transform: uppercase;381 letter-spacing: 0.2em;382 font-size: 8px;383 color: var(--peach-t2);384 margin-bottom: 10px;385}386387.peach-settings-row {388 display: flex;389 justify-content: space-between;390 align-items: center;391 min-height: 36px;392 gap: 12px;393 font-size: 13px;394 color: var(--peach-t1);395 padding: 2px 0;396}397398.peach-settings-row--meta {399 min-height: 28px;400 font-size: 11px;401 color: var(--peach-t2);402}403404.peach-settings-row--top {405 align-items: flex-start;406 padding-top: 4px;407}408409.peach-settings-row-main {410 display: flex;411 flex-direction: column;412 gap: 2px;413 flex: 1;414 min-width: 0;415}416```417418### 4.3 Auto-Lock Setting419420Add `autoLockMinutes` to the VaultSettings shape (already defined, just not exposed in UI). Add a row under "Lock on close":421422```tsx423<div class="peach-settings-row">424 <span>Auto-lock after</span>425 <select426 class="peach-settings-select"427 value={String(autoLockMinutes())}428 onChange={(e) => void applyAutoLock(Number(e.currentTarget.value))}429 >430 <option value="1">1 minute</option>431 <option value="5">5 minutes</option>432 <option value="15">15 minutes</option>433 <option value="30">30 minutes</option>434 <option value="0">Never</option>435 </select>436</div>437```438439Wire `applyAutoLock` → `UPDATE_SETTINGS { autoLockMinutes }` → in `vault.ts` `updateSettings` → persist. In the alarms handler, read `settings.autoLockMinutes` (default: 5) to set the idle detection interval.440441```css442.peach-settings-select {443 font-family: "IBM Plex Mono", monospace;444 font-size: 11px;445 background: transparent;446 border: 1px solid var(--peach-t3);447 border-radius: 4px;448 color: var(--peach-t1);449 padding: 3px 6px;450 cursor: pointer;451 outline: none;452}453.peach-settings-select:focus {454 border-color: var(--peach-accent);455}456```457458### 4.4 Duress Password Section (Polish)459460The duress form already works functionally. Visual polish:4614621. Wrap the form in `.peach-duress-form` with consistent `gap: 12px` between fields.4632. Use `.peach-settings-input` (not `.peach-underline-input`) for the password inputs — consistent with the rest of the settings page.4643. The status indicator (on/off) should be a styled pill, not a div with inline styles:465466```css467.peach-duress-status-pill {468 font-family: "IBM Plex Mono", monospace;469 font-size: 9px;470 text-transform: uppercase;471 letter-spacing: 0.12em;472 padding: 2px 8px;473 border-radius: 12px;474 background: var(--peach-t3);475 color: var(--peach-t2);476 flex-shrink: 0;477}478.peach-duress-status-pill.is-active {479 background: rgba(217, 104, 48, 0.15);480 color: var(--peach-accent);481}482```4834844. 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.4854865. 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.487488### 4.5 Remove "Last sync: local only" Hardcoded Text489490In the Vault section:491```tsx492<div class="peach-settings-row peach-settings-row--meta">493 <span>Last sync</span>494 <span class="peach-settings-value">local only</span> {}495</div>496```497498Remove this row. The sync section already shows the actual sync status. Two contradictory "last sync" indicators are worse than one.499500### 4.6 Consistent Button Sizing501502All 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.503504---505506## 5. Per-Entry Canary Settings507508The 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.509510### 5.1 Location511512In `Entry.tsx`, after the notes section, before the delete row, add a collapsed "Advanced" disclosure:513514```tsx515{}516<Show when={entry().type !== "note"}>517 <div class="peach-entry-advanced">518 <button519 class="peach-entry-advanced-toggle"520 onClick={() => setShowAdvanced(!showAdvanced())}521 >522 <span class="peach-label">advanced</span>523 <ChevronGlyph open={showAdvanced()} />524 </button>525 <Show when={showAdvanced()}>526 <CanarySection entry={entry()} onSaved={props.onFieldSaved} />527 </Show>528 </div>529</Show>530```531532### 5.2 CanarySection Component533534A self-contained sub-component that shows:5355361. **Toggle row**: "Canary alert" + toggle. When toggled on, reveals the webhook URL input.5372. **Webhook URL input** (inline edit style — same `.peach-inline-input` pattern): `https://hooks.zapier.com/...`5383. **Label input** (optional): human-readable name for this canary.5394. **"Send test ping"** button: fires `FIRE_CANARY_PING` with `trigger: "test"`, shows "sent ✓" or "failed — [reason]".5405. **Ping history**: if `entry.canary.lastPinged` is set, show *"Last accessed: [relative time]"* and *"Total alerts: [pingCount]"*.541542```tsx543function CanarySection(props: { entry: VaultEntry; onSaved: () => void | Promise<void> }) {544 const [enabled, setEnabled] = createSignal(Boolean(props.entry.canary));545 const [webhookUrl, setWebhookUrl] = createSignal(props.entry.canary?.webhookUrl ?? "");546 const [label, setLabel] = createSignal(props.entry.canary?.label ?? "");547 const [saving, setSaving] = createSignal(false);548 const [testResult, setTestResult] = createSignal<string | null>(null);549 const [testing, setTesting] = createSignal(false);550551 async function save() {552 setSaving(true);553 const canary = enabled() && webhookUrl().trim()554 ? { webhookUrl: webhookUrl().trim(), label: label().trim() || undefined,555 lastPinged: props.entry.canary?.lastPinged, pingCount: props.entry.canary?.pingCount }556 : undefined;557 await sendMessage({ type: "UPDATE_ENTRY", payload: { id: props.entry.id, patch: { canary } } });558 setSaving(false);559 await props.onSaved();560 }561562 async function testPing() {563 const url = webhookUrl().trim();564 if (!url) return;565 setTesting(true);566 setTestResult(null);567 const res = await sendMessage({568 type: "FIRE_CANARY_PING",569 payload: { webhookUrl: url, entryTitle: props.entry.title, trigger: "test" },570 }) as { ok: boolean; error?: string };571 setTestResult(res.ok ? "sent ✓" : `failed — ${res.error ?? "check URL"}`);572 setTesting(false);573 }574575 return (576 <div class="peach-canary-section">577 <div class="peach-field-value-row">578 <span class="peach-field-value" style="font-size:12px;">canary alert</span>579 <button580 class={`peach-toggle ${enabled() ? "is-on" : ""}`}581 onClick={() => { setEnabled(!enabled()); void save(); }}582 aria-label="Toggle canary"583 >584 <span class="peach-toggle-knob" />585 </button>586 </div>587 <Show when={enabled()}>588 <div class="peach-inline-field-wrap" style="margin-top:8px;">589 <input590 class="peach-inline-input"591 placeholder="https://hooks.zapier.com/..."592 value={webhookUrl()}593 onInput={(e) => setWebhookUrl(e.currentTarget.value)}594 onBlur={() => void save()}595 />596 <input597 class="peach-inline-input"598 placeholder="label (optional, e.g. AWS decoy)"599 value={label()}600 onInput={(e) => setLabel(e.currentTarget.value)}601 onBlur={() => void save()}602 />603 <div style="display:flex;align-items:center;gap:8px;margin-top:4px;">604 <button605 class="peach-btn peach-btn--ghost peach-btn--sm"606 onClick={() => void testPing()}607 disabled={testing() || !webhookUrl().trim()}608 >609 {testing() ? "sending..." : "test ping"}610 </button>611 <Show when={testResult()}>612 <span style="font-size:10px;color:var(--peach-t2);font-family:var(--font-mono);">{testResult()}</span>613 </Show>614 </div>615 <Show when={props.entry.canary?.lastPinged}>616 <p style="font-size:10px;color:var(--peach-t2);margin-top:4px;">617 Last triggered: {new Date(props.entry.canary!.lastPinged!).toLocaleDateString()}618 {" · "}{props.entry.canary?.pingCount ?? 0} total alerts619 </p>620 </Show>621 </div>622 </Show>623 </div>624 );625}626```627628### 5.3 Canary CSS Additions629630```css631.peach-entry-advanced {632 margin-top: 12px;633 border-top: 1px solid var(--peach-t3);634 padding-top: 10px;635 position: relative;636 z-index: 1;637}638.peach-entry-advanced-toggle {639 display: flex;640 align-items: center;641 justify-content: space-between;642 width: 100%;643 background: transparent;644 border: none;645 cursor: pointer;646 padding: 0;647 color: var(--peach-t2);648}649.peach-canary-section {650 margin-top: 10px;651 display: grid;652 gap: 6px;653}654```655656### 5.4 Canary Pings Still Fire Silently657658When a user **copies a password or username** from Entry.tsx that has `entry.canary`, Peach should fire the webhook silently. Wire this in `copyField()`:659660```tsx661async function copyField(text: string, label: string, button?: HTMLButtonElement) {662 const response = await sendMessage({ type: "COPY", payload: { text, label } });663 if ((response as { ok: boolean }).ok) {664 665666 667 const entry = props.entry;668 if (entry?.canary?.webhookUrl) {669 const trigger = label === "password" ? "password_copied" : "username_copied";670 void sendMessage({671 type: "FIRE_CANARY_PING",672 payload: { webhookUrl: entry.canary.webhookUrl, entryTitle: entry.title, trigger },673 });674 }675 }676}677```678679---680681## 6. CJK Phrase Import — QR Scan Path682683Currently, 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:684685> *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.*686687The current QR display path on export is correct as-is. No changes needed.688689---690691## 7. Entry Detail — Canary Badge (Remove from Header, Keep in Advanced)692693Currently (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.694695---696697## 8. `shared/types.ts` — MessageType Union Update698699Add `BATCH_ADD_ENTRIES` to the `MessageType` union so TypeScript doesn't warn on the new message:700701```ts702export type MessageType =703 | "CREATE_VAULT"704 | "UNLOCK"705 | "LOCK"706 | "IS_UNLOCKED"707 | "HAS_VAULT"708 | "GET_ENTRIES"709 | "ADD_ENTRY"710 | "BATCH_ADD_ENTRIES" 711 | "UPDATE_ENTRY"712 | "DELETE_ENTRY"713 714```715716---717718## 9. File Download in Extension Context719720The `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.721722If 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:723724```ts725function downloadBlob(content: string, mimeType: string, filename: string): void {726 try {727 const url = URL.createObjectURL(new Blob([content], { type: mimeType }));728 const a = document.createElement("a");729 a.href = url;730 a.download = filename;731 document.body.appendChild(a); 732 a.click();733 document.body.removeChild(a);734 URL.revokeObjectURL(url);735 } catch (err) {736 console.error("[Peach] Download failed:", err);737 throw err; 738 }739}740```741742Note: `a.click()` without `document.body.appendChild(a)` can fail in some Firefox/Chromium extension contexts. Adding the append/remove step is a safe guard.743744---745746## 10. CSS Classes Still Missing747748Check that all of these exist in `popup.css` before declaring the settings page done. Add any that are absent:749750| Class | Purpose |751|---|---|752| `.peach-settings-input` | Full-width underlined input used in Export/import password fields |753| `.peach-settings-select` | Dropdown select element (§4.3) |754| `.peach-duress-status-pill` / `.is-active` | Duress on/off pill (§4.4) |755| `.peach-entry-advanced` | Wrapper for the advanced disclosure in Entry |756| `.peach-entry-advanced-toggle` | Button row that expands advanced section |757| `.peach-canary-section` | Canary sub-form inside advanced |758| `.peach-restore-warning` | Red warning box in Import |759| `.peach-provider-chips` / `.peach-provider-chip` | Supported format chips in Import |760| `.peach-import-summary` | "Found N entries, M skipped" text |761| `.peach-import-preview` / `.peach-import-preview-row` | 8-row preview table |762763Run `npx tsc --noEmit` and check browser devtools for CSS missing-class warnings after implementation.764765---766767## 11. Implementation Order768769Implement in this exact order. Each step should compile cleanly before proceeding.7707711. **Fix toggle CSS** (§1.2) — no logic changes, safe7722. **Fix SolidJS early-return bugs** (§1.1) — enables CJK export/import to work7733. **Fix notes data loss** (§1.3) — critical data integrity fix7744. **Fix download helper** (§9) — defensive guard7755. **Add BATCH_ADD_ENTRIES** (§1.4 + §8) — background + messages + Import.tsx7766. **Add auto-unlock-on-enter for encrypted import** (§1.5)7777. **Fix unknown-format error message** (§1.6)7788. **Export pre-flight and confirmation** (§2.1, §2.2, §2.3)7799. **Import merge/replace choice** (§3.1)78010. **Settings page redesign** (§4) — visual + auto-lock wiring78111. **Per-entry canary section** (§5)78212. **Typecheck + build + version bump to 1.4.21**78313. **Smoke test** — export encrypted JSON, re-import it, verify all entries round-trip including notes784785---786787## 12. Smoke Test Protocol788789Before shipping, verify this exact flow manually:790791**Export round-trip:**7921. Open Peach. Confirm vault has at least: 1 login, 1 note, 1 entry with TOTP, 1 entry with tags.7932. Go to Settings → Export → Encrypted file backup.7943. Enter a 14-character password + confirm. Click "Download encrypted backup".7954. Verify file downloads. Open it in a text editor — confirm it's JSON with `"type":"peach-export"`.7965. Go to Settings → Import. Select the downloaded file. Enter the same password. Click "Decrypt →".7976. Verify preview shows correct entry count. Click Import.7987. Verify vault now contains the expected entries including the note and TOTP entry.799800**CJK phrase round-trip:**8011. Go to Settings → Export → CJK phrase backup.8022. Enter a 5-word diceware passphrase (use the "Generate" button). Confirm it. Click "Export →".8033. After generation, click "Copy phrase". Open a text editor and paste.8044. Go to Settings → Import → Restore from CJK phrase.8055. Paste the phrase. Verify "✓ Phrase recognised" appears.8066. Enter the same passphrase. Click "Restore".8077. Verify entries are restored.808809**Toggle persistence:**8101. Open Settings. Toggle "Lock on close" off.8112. Close the popup completely. Re-open.8123. Verify toggle is still off. If it reverts to on, the `UPDATE_SETTINGS` persistence is broken.813814**Canary config:**8151. Open an entry. Scroll to "advanced". Expand.8162. Toggle canary on. Enter a real webhook URL. Click elsewhere (blur).8173. Close popup. Re-open. Open same entry. Verify canary is still on with URL intact.8184. Click "test ping". Verify webhook receives the payload.819820---821822## 13. Version Bump823824After 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.