1# Peach Browser Extension — Completion Spec · v1.2 baseline2**Generated:** 2026-04-20 3**Current version:** 1.1.1 4**Target:** production-quality, daily-driver password manager56---78## Context & guiding principle910Every decision in this spec is held to one standard: **seamless, magical, snappy, and brilliant.** That means zero friction on the critical path (fill credentials → copy TOTP → move on), zero surprises on import/export, and an interface that rewards the user for every interaction. Slow, janky, or confusing is always a bug.1112---1314## Baseline state (v1.1.1)1516### What works17- Popup UI pixel-matched to peachpasswords.com demo; 520 px shell, archive fonts, peach glyph wordmark18- AES-256-GCM vault + Argon2id key derivation19- HIBP k-anonymity breach monitoring (per-entry count, bulk "check all")20- Import: Bitwarden JSON, Proton Pass JSON, 1Password JSON, Chrome/LastPass/1Password CSV, Peach encrypted JSON21- Export: Peach encrypted JSON, plaintext CSV, CJK phrase backup (full round-trip, 43 tests)22- Content script: basic heuristic field detection, React/Vue-aware fill, shadow-DOM autofill selector dropdown, identity helper on signup forms, TOTP clipboard toast23- Settings persistence (theme, accent) through lock/unlock24- Auto-lock on system idle (chrome.idle, 60 s threshold)25- 49 Vitest tests pass; typecheck clean; build succeeds2627### Known gaps (drives this spec)28- **Legacy Peach JSON** (`exportFormat: "peach-vault"`) not recognized → silent UNSUPPORTED_IMPORT_FORMAT for any user migrating from peach legacy29- **Full ML autofill UX** — ONNX model + feature extractor exist but the trigger loop (field focus → AUTOFILL_QUERY → dropdown → fill → TOTP) is not wired; no mutation observer for SPAs; no field icon; no TOTP autofill; no login-form detection on all fields (currently only signup helper is active)30- **QR code export** — not implemented; critical for device-to-device vault transfer and TOTP handoff to authenticator apps31- **Bundle size** — main popup chunk is **1016 KB** (uncompressed); first open feels sluggish; target < 250 KB for initial paint32- **Polish gaps** — no favicon in autofill dropdown, no fuzzy search, no inline password strength in Add/Edit, no keyboard shortcuts, no entry view animations3334---3536## Version bump rule3738Bump `version` in `package.json`, `manifest.json`, and `package-lock.json` **after every task**. Use semantic versioning:39- Each task below is a MINOR bump (new user-facing capability)40- Polish/splitting tasks are PATCH bumps4142---4344## Task 1 — Legacy Peach JSON import *(target: v1.1.2)*4546### Why47Anyone migrating from the legacy peachlegacy codebase exports a `peach-vault` JSON file. That file is currently rejected by `parseImportSource()`. This is a hard blocker for any real migration.4849### Legacy format schema50The legacy app exports plaintext JSON (no outer encryption) in this shape:5152```json53{54 "exportFormat": "peach-vault",55 "exportFormatVersion": 2,56 "exportedAt": "2025-01-15T10:00:00.000Z",57 "exportedAtMs": 1736935200000,58 "entryCount": 42,59 "passkeyPrivateKeysWrapped": false,60 "entries": [61 {62 "id": "<uuid>",63 "type": "login",64 "modified": 1736935100000,65 "created": 1736930000000,66 "name": "GitHub",67 "favorite": false,68 "tags": [],69 "login": {70 "urls": ["https://github.com"],71 "username": "june",72 "password": "supersecret",73 "totp": {74 "secret": "BASE32SECRET",75 "algorithm": "SHA1",76 "digits": 6,77 "period": 3078 },79 "customFields": []80 }81 }82 ]83}84```8586`type` may also be `"card"`, `"identity"`, or `"note"` — skip those silently (count as skipped items).8788### Implementation8990**`src/shared/import.ts`**91921. Add `"peach-legacy-json"` to the `ImportFormat` union type.932. In `detectImportFormat()`, before the `csv` fallback, add:94 ```ts95 if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json";96 ```973. Add `parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }`:98 - Parse JSON; guard on `exportFormat === "peach-vault"`.99 - `totalItems` = `data.entries.length`.100 - Filter for `entry.type === "login"`.101 - Map each login entry:102 - `title` ← `entry.name ?? "Untitled"`103 - `url` ← `entry.login.urls?.[0] ?? ""`104 - `username` ← `entry.login.username ?? ""`105 - `password` ← `entry.login.password ?? ""`106 - `totp` ← `entry.login.totp?.secret ?? undefined`107 - `notes` ← `entry.login.notes ?? undefined`108 - `createdAt` ← `entry.created ?? Date.now()`109 - `modifiedAt` ← `entry.modified ?? Date.now()`110 - Drop passkeys, customFields, tags, favorite (no equivalent fields in `VaultEntry`). Do not silently inflate skipped count for dropped sub-fields — only count whole entries that were skipped.1114. Wire into the `switch` in `parseImportSource()` with `label: "Peach (legacy)"`, `requiresPassword: false`.112113**`src/popup/views/Import.tsx`**114115Add `"peach-legacy-json"` to the format label map so the preview header shows `"Peach (legacy)"` instead of the raw format string.116117**`src/shared/import.test.ts`**118119Add at minimum:120- Parses a valid `peach-vault` export and returns the correct `ImportableEntry[]`121- Skips `type !== "login"` entries and reports them in `skippedItems`122- Handles missing optional fields (`totp`, `notes`, `urls`) without throwing123- Falls back gracefully on malformed JSON (returns `format: "unknown"`)124125### Acceptance criteria126- `detectImportFormat(legacyJson)` returns `"peach-legacy-json"`127- Import preview shows login entries with correct title/url/username128- Card, identity, note entries counted in skipped items129- `npm test` still passes; `npm run typecheck` clean130131---132133## Task 2 — QR code export *(target: v1.1.3)*134135### Why136QR codes are the fastest path for two workflows users need daily: (a) transferring a TOTP secret to a new authenticator app, and (b) securely moving a vault backup to another device without USB or cloud. Both should feel instant and obvious.137138### Dependency139Add `qrcode` (browser-safe, no Node deps, ~40 KB min+gz):140```141npm install qrcode142npm install --save-dev @types/qrcode143```144145### 2a — TOTP QR in entry detail view146147**`src/popup/views/Entry.tsx`**148149When an entry has a `totp` field, add a small "show QR" icon/button beside the TOTP display (right of the copy button). On click:150- Construct the `otpauth://totp/` URI from the entry: `otpauth://totp/${encodeURIComponent(entry.title)}?secret=${entry.totp}&issuer=${encodeURIComponent(domain)}&algorithm=SHA1&digits=6&period=30`151- Generate a QR code as a `data:image/png;base64,...` string using `QRCode.toDataURL(uri, { width: 220, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })` so it matches the Peach palette152- Show the QR inside a full-overlay modal (same overlay pattern as other views) with: title "Scan with your authenticator", the QR image centred, the raw URI in a monospace pre block below (scrollable, selectable), and a close button153- The overlay is dismissed by pressing Escape or clicking the close button154155The QR generation (`QRCode.toDataURL`) should be dynamically imported so it does not land in the main chunk:156```ts157const { default: QRCode } = await import("qrcode");158```159160**UX requirement:** the QR must be large enough to scan reliably from ~40 cm (220 px at 1× CSS px satisfies this inside the 520 px popup).161162### 2b — Vault QR in Export view (CJK phrase → QR)163164The CJK phrase backup already produces a compact, human-copyable string (~800 CJK characters for a 100-entry vault). A QR code of that string is the ideal device-to-device transfer mechanism — it fits comfortably within QR version 40 capacity (~2,953 alphanumeric chars at ECC M).165166**`src/popup/views/CJKExport.tsx`**167168After the CJK phrase is generated and displayed, add a secondary action button "Show as QR" below the phrase display. On click:169- Dynamically import `qrcode`170- Generate QR of the raw CJK phrase string: `QRCode.toDataURL(phraseString, { errorCorrectionLevel: 'M', width: 420, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })`171- Display in the same overlay pattern as 2a, with label "Scan with Peach on another device" and the instruction "Open Peach → Import → CJK phrase, then scan"172- Include a copy button for the text phrase so the user can choose either transfer method173174**Acceptance criteria**175- Clicking "show QR" on a TOTP entry renders a scannable QR code that an authenticator app (Google Authenticator, Aegis) reads correctly176- Clicking "Show as QR" after CJK export renders a QR that round-trips correctly through CJK import when scanned177- Neither import adds to the initial popup JS chunk (dynamic import verified: `npm run build` shows `qrcode` in a separate chunk)178- `npm run typecheck` clean179180---181182## Task 3 — Full ML autofill UX *(target: v1.2.0)*183184### Why185This is the core daily-driver feature. The legacy system was battle-tested across months of real-world use on thousands of sites. The ML model (`models/form_detector.onnx`) and feature extraction code already exist in peach-browser. What's missing is the full trigger loop and UX surface. This task completes it.186187### Architecture overview188189The flow is:1901. Content script observes field focus events (including inside shadow roots via deep traversal)1912. For any password-type field or ML-classified username/email field in a login context, fire `AUTOFILL_QUERY` with the page's registrable domain1923. Background returns matching vault entries1934. Dropdown appears anchored below the focused field (closed shadow DOM)1945. User selects an entry (click or keyboard) → `GET_ENTRY_FOR_FILL` → `fillFields()`1956. If entry has `totp`, copy to clipboard and show toast "TOTP copied"1967. Mutation observer watches for SPA DOM changes and re-runs detection197198### 3a — Deep input traversal199200**`src/content/detect.ts`**201202Add `querySelectorAllDeep<T extends Element>(root: Document | ShadowRoot | Element, selector: string): T[]`:203- Collects matching elements from `root.querySelectorAll(selector)`, then recursively descends into any `element.shadowRoot` found in the tree204- Used by `detectLoginFields()` and `collectPasswordFields()` to replace the current flat `document.querySelectorAll`205206Replace the existing flat `querySelectorAll` in `detectLoginFields()` with `querySelectorAllDeep(document, "input")`.207208Add `getDeepActiveInput(): HTMLInputElement | null` — walks `document.activeElement`, then repeatedly dereferences `.shadowRoot?.activeElement` until no further shadow host is found.209210### 3b — Login-form detection211212**`src/content/detect.ts`**213214Add `isLoginForm(fields: DetectedField[]): boolean`:215- Returns true if fields contain at least one password field AND at least one username/email field216- OR if there is exactly one password field and the autocomplete is `current-password`217218Add `isSignupForm(fields: DetectedField[]): boolean` (update existing if present):219- Returns true if any password field has `autocomplete="new-password"`, name/id containing "new" or "confirm", or there are 2+ password fields in the collection220221The existing content script (`index.ts`) currently only shows the identity helper on signup forms. This task extends it to also show the autofill dropdown on login forms (using `isLoginForm`).222223### 3c — Field icon224225**`src/content/inject.ts`**226227Add `injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void`:228- Inserts a small 18×18 px circular icon (the peach SVG mark, same as `PeachMark.tsx` but inline SVG) into the field's parent container229- Positioned absolute, right-aligned within the field, vertically centered230- `z-index: 2147483646`; pointer-events on icon only231- Returns a cleanup function that removes the icon232- Clicking the icon calls `onClick` (which manually triggers the autofill dropdown even if the field wasn't freshly focused)233234The icon must not break field layout: set `padding-right` on the field to `field.style.paddingRight + 26px` when injected, restore on cleanup.235236### 3d — Autofill dropdown (upgrade `inject.ts`)237238**`src/content/inject.ts`**239240Upgrade `showAutofillSelector()` with:241- **Favicon**: prepend a 16×16 `<img>` to each row loading `https://icons.duckduckgo.com/ip3/${domain}.ico` (extract domain from `entry.url`), with a fallback to a generic key SVG on error242- **Keyboard navigation**: `ArrowDown`/`ArrowUp` moves focus between rows; `Enter` selects; `Escape` dismisses; `Tab` dismisses (don't trap focus)243- **Repositioning**: use a `ResizeObserver` + `requestAnimationFrame` loop to keep the dropdown anchored if the field moves (SPA transitions, sticky headers)244- **Empty state**: if `entries.length === 0`, show a single row "No saved logins for this site" (non-interactive, muted style) rather than not showing the dropdown at all — gives the user confirmation that Peach is active245- **TOTP indicator**: if an entry has a `totp` field, show a small "⏱" badge on the right side of the row246247### 3e — Trigger loop248249**`src/content/index.ts`** — replace/extend the current content script:250251```252on document "focusin":253 field = getDeepActiveInput()254 if field is null or not visible: return255 if field type is "password" or ML predicts "username"/"email":256 fields = detectLoginFields()257 if isLoginForm(fields):258 entries = await sendMessage(AUTOFILL_QUERY, { domain: getPageDomain() })259 if entries.length > 0:260 dismiss = showAutofillSelector(field, entries, async (id) => {261 entry = await sendMessage(GET_ENTRY_FOR_FILL, { id })262 fillFields(usernameEl, passwordEl, entry.username, entry.password)263 if entry.totp:264 totpCode = await generateTOTP(entry.totp)265 navigator.clipboard.writeText(totpCode)266 showToast("TOTP copied · " + totpCode)267 })268 injectFieldIcon(field, () => dismiss()) // icon re-opens on click269 else if isSignupForm(fields):270 [existing identity helper logic]271```272273The ML classification for the focused field should be done via `ContentMLDetector` from `content-ml-detector.ts` (already exists). Include a 150 ms timeout fallback to heuristic `detectLoginFields()` if ML inference stalls.274275### 3f — Mutation observer (SPA support)276277**`src/content/index.ts`**278279Add `setupMutationObserver()`:280```ts281const observer = new MutationObserver(debounce(() => {282 283 284}, 120));285observer.observe(document.body, {286 childList: true, subtree: true,287 attributes: true,288 attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"]289});290```291292Call `setupMutationObserver()` once at content script init. On each mutation batch, check if any previously-unseen password inputs have appeared; if so, re-run field detection and re-attach icon + focus listeners on the new inputs.293294### 3g — TOTP autofill295296If the page has a visible TOTP input (ML label `"totp"` or `autocomplete="one-time-code"` or `name/id` containing `otp|totp|2fa|mfa|code`), and the user just autofilled from a matching entry that has `totp`:297- After a 400 ms delay (allow page to react to password fill), check if the TOTP field is still empty298- If so, generate the current TOTP code and fill it directly (using `setNativeValue`)299- Show toast "TOTP autofilled"300301### Acceptance criteria302- On a standard login form (GitHub, Notion, Linear) the autofill dropdown appears when the username/email field is focused303- Keyboard selection fills both fields without page reload304- After fill, TOTP code is on clipboard with toast confirmation305- On an SPA that renders a login form after navigation, the dropdown appears on the newly-rendered form306- Dropdown appears inside fields rendered in a shadow root307- `npm run typecheck` clean; `npm run build` succeeds; `node test-extension.mjs` passes308309---310311## Task 4 — Code splitting *(target: v1.2.1)*312313### Why314The main popup JS chunk is **1016 KB**. On first open (cold service worker), this delays interactive time noticeably. The goal is an initial chunk < 250 KB so the vault list renders within one animation frame of popup opening.315316### What is heavy317318| Module | Why heavy |319|---|---|320| CJKExport + CJKImport | zstd WASM init, argon2 from hash-wasm, diceware wordlist, zxcvbn |321| Export | AES-GCM crypto module, CSV serializer |322| Import | PapaParse, all format parsers, backup decrypt |323| Generator | zxcvbn (already dep but bundled eagerly) |324| onnxruntime-web | ONNX inference (should already be background-only, verify) |325326### Implementation327328**`src/popup/App.tsx`**329330Replace static imports of heavy views with SolidJS lazy components:331332```tsx333import { lazy } from "solid-js";334335const ImportView = lazy(() => import("./views/Import"));336const ExportView = lazy(() => import("./views/Export"));337const CJKExport = lazy(() => import("./views/CJKExport"));338const CJKImport = lazy(() => import("./views/CJKImport"));339const SettingsView = lazy(() => import("./views/Settings"));340const GeneratorView = lazy(() => import("./views/Generator"));341```342343Wrap each lazy usage in `<Suspense fallback={<div class="peach-loading">…</div>}>`. The loading state should be a minimal spinner or the Peach wordmark with a subtle pulse — never a blank white flash.344345**`src/popup/views/Import.tsx` and `Export.tsx`**346347Move the `qrcode` import (from Task 2) to `() => import("qrcode")` at the call site — already specified in Task 2. Verify no static `qrcode` import lands in the main chunk.348349**`src/crypto/*.ts`**350351The CJK modules should already be separate chunks since they're only imported from `CJKExport`/`CJKImport` views. Verify after splitting. If they still appear in the main chunk, add an explicit `/* @vite-ignore */` comment and dynamic-import the crypto entry point from within the lazy view.352353**`vite.config.ts`**354355Add manual chunk grouping to keep the split predictable:356357```ts358build: {359 target: "esnext",360 emptyOutDir: false,361 rollupOptions: {362 output: {363 manualChunks: {364 "vendor-solid": ["solid-js"],365 "vendor-otp": ["otpauth"],366 "vendor-parse": ["papaparse"],367 "vendor-qr": ["qrcode"],368 }369 }370 }371}372```373374### Acceptance criteria375- `npm run build` output shows no single JS chunk > 350 KB (uncompressed)376- The main popup chunk (`index.html-*.js`) is < 250 KB377- Opening the popup on a cold start renders the shell (wordmark + lock screen or vault list) within one frame; Import/Export/Settings overlay loads in < 300 ms on first open (Suspense fallback visible only briefly)378- All existing Playwright and Vitest tests still pass379380---381382## Task 5 — Polish pass *(target: v1.2.2)*383384This task has no new features — it is entirely about feel. Every interaction should be tight.385386### 5a — Fuzzy search387388**`src/popup/views/Vault.tsx`**389390Replace the current substring search with a lightweight fuzzy match:391```ts392function fuzzyMatch(query: string, target: string): boolean {393 query = query.toLowerCase();394 target = target.toLowerCase();395 let qi = 0;396 for (let i = 0; i < target.length && qi < query.length; i++) {397 if (target[i] === query[qi]) qi++;398 }399 return qi === query.length;400}401```402Match against `entry.title + " " + entry.url + " " + entry.username`. Sort results so exact prefix matches appear before fuzzy matches. No external dependency.403404### 5b — Inline password strength405406**`src/popup/views/Add.tsx`**407408Under the password input, add a strength bar using `zxcvbn` (already a dep):409- A 4-segment bar (0–4 score), coloured: red → orange → yellow → green410- Label: "weak" / "fair" / "good" / "strong"411- Only appears once the user has typed ≥ 4 characters412- zxcvbn import: `import { default as zxcvbn } from "zxcvbn"` — this module is already in the vendor bundle; no new dependency413414### 5c — Keyboard shortcuts415416**`src/popup/App.tsx`**417418Global `keydown` listener (when popup is focused):419| Key | Action |420|---|---|421| `Escape` | Close the topmost open overlay (settings, import, export, CJK, generator, add) |422| `/` or `Ctrl+F` | Focus the search input (from any unlocked non-overlay state) |423| `Ctrl+L` | Lock vault |424425**`src/popup/views/Entry.tsx`**426427When an entry is open:428| Key | Action |429|---|---|430| `c` | Copy password |431| `u` | Copy username |432| `t` | Copy TOTP (if present) |433| `e` | Open edit mode |434| `Escape` | Back to vault list |435436### 5d — Entry favicon437438**`src/popup/views/Vault.tsx`** and **`src/popup/views/Entry.tsx`**439440Replace the generic key icon in list rows with a 16×16 favicon loaded from `https://icons.duckduckgo.com/ip3/${registrableDomain}.ico`. On error fall back to the key icon. Cache in a `Map<string, string>` (domain → data URL) within the component lifetime to avoid redundant fetches as the user scrolls.441442### 5e — View transition animations443444**`src/popup/styles/popup.css`**445446Overlays currently appear without animation. Add:447```css448.peach-overlay-view {449 animation: overlay-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;450}451@keyframes overlay-in {452 from { opacity: 0; transform: translateY(8px); }453 to { opacity: 1; transform: translateY(0); }454}455```456457Entry detail navigation (list row → detail panel):458```css459.peach-stage-content {460 transition: opacity 120ms ease, transform 120ms ease;461}462.peach-stage-content.navigating {463 opacity: 0;464 transform: translateX(6px);465}466```467Add/remove `.navigating` in `App.tsx` during route transitions using a `createEffect` on the active entry signal.468469### 5f — Copy button drain animation (complete the website parity)470471**`src/popup/views/Entry.tsx`**472473On successful copy, the copy button should:4741. Switch icon to a checkmark SVG4752. Show a brief CSS `drain` animation (the website demo has this)4763. Revert after 1800 ms477478This should already be partially present from the v1.0.8 website-parity pass — verify it is wired and working; if not, complete it.479480### 5g — Auto-focus discipline481482Every overlay that opens should immediately focus the first interactive element:483- Add entry overlay → focus the title input484- Search / vault list → focus the search input485- CJK import → focus the passphrase input486- QR overlay → focus the close button (nothing to type)487488Use SolidJS `onMount` with `el.focus()`.489490### Acceptance criteria491- Typing a partial entry name with gaps finds the entry via fuzzy match492- Password strength bar appears while typing in Add view493- `Escape` closes the topmost overlay from any state494- `/` focuses search from the vault list495- `c` copies password on entry detail496- Entry list rows show site favicons497- Overlays animate in smoothly; no visible layout jump498- `npm run typecheck` clean; `npm test` passes; `npm run build` succeeds; `node test-extension.mjs` confirms vault persistence499500---501502## Task 6 — Final verification gate *(target: v1.2.2 — same as polish)*503504After all tasks are complete, run the full suite in order. **All four must pass before declaring the spec complete.**505506```bash507npm run typecheck508npm test509npm run build510DISPLAY=:99 node test-extension.mjs511```512513Expected output from smoke test:514```515Extension ID: <id>516Popup title: Peach517Entry created518Entry visible after save: true519Create vault visible after reopen: false520Unlock visible after reopen: false (or true if lockOnClose is on)521Entry still present after unlock: true522Done.523```524525CONTINUITY.md must be updated with a `[OUTCOMES]` entry covering every task completed.526527---528529## Appendix A — ML feature vector reference530531The ONNX model at `public/models/form_detector.onnx` expects a `float32[1,45]` tensor. Features in order:532533| Index | Feature | Notes |534|---|---|---|535| 0–7 | `type_text/email/password/tel/number/search/url/other` | one-hot |536| 8–14 | `auto_username/email/current-password/new-password/one-time-code/off/other` | one-hot |537| 15–18 | `name_has_user/login/email/pass` | regex matchScore |538| 19 | `name_length` | / 50 |539| 20–23 | `id_has_user/login/email/pass` | regex matchScore |540| 24 | `id_length` | / 50 |541| 25–27 | `placeholder_has_user/email/pass` | regex matchScore |542| 28 | `placeholder_length` | / 100 |543| 29–31 | `aria_label_has_user/email/pass` | regex matchScore |544| 32 | `aria_label_length` | / 100 |545| 33–35 | `parent_is_form/div/section` | one-hot on closest block parent |546| 36 | `sibling_count` | / 10 |547| 37 | `has_password_sibling` | bool |548| 38 | `has_email_sibling` | bool |549| 39 | `form_has_submit` | bool |550| 40 | `form_action_has_login` | bool |551| 41 | `is_required` | bool |552| 42 | `has_placeholder` | bool |553| 43 | `has_aria_label` | bool |554| 44 | `inputmode_numeric` | bool |555556Output: softmax over `['username', 'password', 'email', 'totp', 'none']`. Argmax taken.557558---559560## Appendix B — Legacy Peach JSON field mapping561562| Legacy field | `VaultEntry` field | Notes |563|---|---|---|564| `entry.name` | `title` | |565| `entry.login.urls[0]` | `url` | First URL only |566| `entry.login.username` | `username` | |567| `entry.login.password` | `password` | |568| `entry.login.totp.secret` | `totp` | Drop algorithm/digits/period |569| `entry.login.notes` | `notes` | If present |570| `entry.created` | `createdAt` | |571| `entry.modified` | `modifiedAt` | |572| `entry.trashedAt` | `deletedAt` | |573| `entry.login.passkey` | — | Drop silently |574| `entry.login.customFields` | — | Drop silently |575| `entry.tags` / `favorite` | — | Drop silently |576| `type !== "login"` | — | Skip, count as skipped |577578---579580## Appendix C — QR size sanity581582| Vault size | CJK phrase length | QR version needed | Fits? |583|---|---|---|---|584| 10 entries | ~120 chars | Version 10 (ECC M) | ✓ |585| 50 entries | ~450 chars | Version 25 (ECC M) | ✓ |586| 100 entries | ~827 chars | Version 33 (ECC M) | ✓ |587| 500 entries | ~3,500 chars | Version 40 (ECC L) | ✓ |588| 1,000 entries | ~6,500 chars | > Version 40 | ✗ — fall back to CJK text display only, hide "Show as QR" button |589590When `phraseString.length > 2900`, hide the "Show as QR" button in `CJKExport.tsx` and show an inline note: "Vault too large for QR — copy the phrase above."591592---593594## Appendix D — What "complete" means595596A password manager extension is production-complete when a user can:5971. **First run** — create a vault in < 30 s, feel secure doing it5982. **Daily use** — open any login page, see the credential dropdown appear, click, done5993. **TOTP** — never manually open an authenticator app; code is on clipboard automatically6004. **Recover** — restore their vault from a CJK phrase on a new device in < 2 minutes6015. **Migrate** — import from any mainstream manager (Bitwarden, 1Password, Chrome, LastPass, Proton Pass, legacy Peach) without losing data6026. **Trust** — zero plaintext password ever touches the network; breach status is visible at a glance603604After v1.2.2 Peach satisfies all six. That is the bar.