peach_completion_spec.md
Peach Browser Extension — Completion Spec · v1.2 baseline
Generated: 2026-04-20
Current version: 1.1.1
Target: production-quality, daily-driver password manager
Context & guiding principle
Every 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.
Baseline state (v1.1.1)
What works
- Popup UI pixel-matched to peachpasswords.com demo; 520 px shell, archive fonts, peach glyph wordmark
- AES-256-GCM vault + Argon2id key derivation
- HIBP k-anonymity breach monitoring (per-entry count, bulk "check all")
- Import: Bitwarden JSON, Proton Pass JSON, 1Password JSON, Chrome/LastPass/1Password CSV, Peach encrypted JSON
- Export: Peach encrypted JSON, plaintext CSV, CJK phrase backup (full round-trip, 43 tests)
- Content script: basic heuristic field detection, React/Vue-aware fill, shadow-DOM autofill selector dropdown, identity helper on signup forms, TOTP clipboard toast
- Settings persistence (theme, accent) through lock/unlock
- Auto-lock on system idle (chrome.idle, 60 s threshold)
- 49 Vitest tests pass; typecheck clean; build succeeds
Known gaps (drives this spec)
- Legacy Peach JSON (
exportFormat: "peach-vault") not recognized → silent UNSUPPORTED_IMPORT_FORMAT for any user migrating from peach legacy - 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)
- QR code export — not implemented; critical for device-to-device vault transfer and TOTP handoff to authenticator apps
- Bundle size — main popup chunk is 1016 KB (uncompressed); first open feels sluggish; target < 250 KB for initial paint
- Polish gaps — no favicon in autofill dropdown, no fuzzy search, no inline password strength in Add/Edit, no keyboard shortcuts, no entry view animations
Version bump rule
Bump version in package.json, manifest.json, and package-lock.json after every task. Use semantic versioning:
- Each task below is a MINOR bump (new user-facing capability)
- Polish/splitting tasks are PATCH bumps
Task 1 — Legacy Peach JSON import (target: v1.1.2)
Why
Anyone 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.
Legacy format schema
The legacy app exports plaintext JSON (no outer encryption) in this shape:
{
"exportFormat": "peach-vault",
"exportFormatVersion": 2,
"exportedAt": "2025-01-15T10:00:00.000Z",
"exportedAtMs": 1736935200000,
"entryCount": 42,
"passkeyPrivateKeysWrapped": false,
"entries": [
{
"id": "<uuid>",
"type": "login",
"modified": 1736935100000,
"created": 1736930000000,
"name": "GitHub",
"favorite": false,
"tags": [],
"login": {
"urls": ["https://github.com"],
"username": "june",
"password": "supersecret",
"totp": {
"secret": "BASE32SECRET",
"algorithm": "SHA1",
"digits": 6,
"period": 30
},
"customFields": []
}
}
]
}
type may also be "card", "identity", or "note" — skip those silently (count as skipped items).
Implementation
src/shared/import.ts
- Add
"peach-legacy-json"to theImportFormatunion type. - In
detectImportFormat(), before thecsvfallback, add:if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json"; - Add
parsePeachLegacyJSON(raw: string): { entries: ImportableEntry[]; totalItems: number }:- Parse JSON; guard on
exportFormat === "peach-vault". totalItems=data.entries.length.- Filter for
entry.type === "login". - Map each login entry:
title←entry.name ?? "Untitled"url←entry.login.urls?.[0] ?? ""username←entry.login.username ?? ""password←entry.login.password ?? ""totp←entry.login.totp?.secret ?? undefinednotes←entry.login.notes ?? undefinedcreatedAt←entry.created ?? Date.now()modifiedAt←entry.modified ?? Date.now()
- 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.
- Parse JSON; guard on
- Wire into the
switchinparseImportSource()withlabel: "Peach (legacy)",requiresPassword: false.
src/popup/views/Import.tsx
Add "peach-legacy-json" to the format label map so the preview header shows "Peach (legacy)" instead of the raw format string.
src/shared/import.test.ts
Add at minimum:
- Parses a valid
peach-vaultexport and returns the correctImportableEntry[] - Skips
type !== "login"entries and reports them inskippedItems - Handles missing optional fields (
totp,notes,urls) without throwing - Falls back gracefully on malformed JSON (returns
format: "unknown")
Acceptance criteria
detectImportFormat(legacyJson)returns"peach-legacy-json"- Import preview shows login entries with correct title/url/username
- Card, identity, note entries counted in skipped items
npm teststill passes;npm run typecheckclean
Task 2 — QR code export (target: v1.1.3)
Why
QR 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.
Dependency
Add qrcode (browser-safe, no Node deps, ~40 KB min+gz):
npm install qrcode
npm install --save-dev @types/qrcode
2a — TOTP QR in entry detail view
src/popup/views/Entry.tsx
When an entry has a totp field, add a small "show QR" icon/button beside the TOTP display (right of the copy button). On click:
- 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 - Generate a QR code as a
data:image/png;base64,...string usingQRCode.toDataURL(uri, { width: 220, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } })so it matches the Peach palette - 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 button
- The overlay is dismissed by pressing Escape or clicking the close button
The QR generation (QRCode.toDataURL) should be dynamically imported so it does not land in the main chunk:
const { default: QRCode } = await import("qrcode");
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).
2b — Vault QR in Export view (CJK phrase → QR)
The 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).
src/popup/views/CJKExport.tsx
After the CJK phrase is generated and displayed, add a secondary action button "Show as QR" below the phrase display. On click:
- Dynamically import
qrcode - Generate QR of the raw CJK phrase string:
QRCode.toDataURL(phraseString, { errorCorrectionLevel: 'M', width: 420, margin: 2, color: { dark: '#F0E8D8', light: '#111009' } }) - 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"
- Include a copy button for the text phrase so the user can choose either transfer method
Acceptance criteria
- Clicking "show QR" on a TOTP entry renders a scannable QR code that an authenticator app (Google Authenticator, Aegis) reads correctly
- Clicking "Show as QR" after CJK export renders a QR that round-trips correctly through CJK import when scanned
- Neither import adds to the initial popup JS chunk (dynamic import verified:
npm run buildshowsqrcodein a separate chunk) npm run typecheckclean
Task 3 — Full ML autofill UX (target: v1.2.0)
Why
This 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.
Architecture overview
The flow is:
- Content script observes field focus events (including inside shadow roots via deep traversal)
- For any password-type field or ML-classified username/email field in a login context, fire
AUTOFILL_QUERYwith the page's registrable domain - Background returns matching vault entries
- Dropdown appears anchored below the focused field (closed shadow DOM)
- User selects an entry (click or keyboard) →
GET_ENTRY_FOR_FILL→fillFields() - If entry has
totp, copy to clipboard and show toast "TOTP copied" - Mutation observer watches for SPA DOM changes and re-runs detection
3a — Deep input traversal
src/content/detect.ts
Add querySelectorAllDeep<T extends Element>(root: Document | ShadowRoot | Element, selector: string): T[]:
- Collects matching elements from
root.querySelectorAll(selector), then recursively descends into anyelement.shadowRootfound in the tree - Used by
detectLoginFields()andcollectPasswordFields()to replace the current flatdocument.querySelectorAll
Replace the existing flat querySelectorAll in detectLoginFields() with querySelectorAllDeep(document, "input").
Add getDeepActiveInput(): HTMLInputElement | null — walks document.activeElement, then repeatedly dereferences .shadowRoot?.activeElement until no further shadow host is found.
3b — Login-form detection
src/content/detect.ts
Add isLoginForm(fields: DetectedField[]): boolean:
- Returns true if fields contain at least one password field AND at least one username/email field
- OR if there is exactly one password field and the autocomplete is
current-password
Add isSignupForm(fields: DetectedField[]): boolean (update existing if present):
- Returns true if any password field has
autocomplete="new-password", name/id containing "new" or "confirm", or there are 2+ password fields in the collection
The 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).
3c — Field icon
src/content/inject.ts
Add injectFieldIcon(field: HTMLInputElement, onClick: () => void): () => void:
- Inserts a small 18×18 px circular icon (the peach SVG mark, same as
PeachMark.tsxbut inline SVG) into the field's parent container - Positioned absolute, right-aligned within the field, vertically centered
z-index: 2147483646; pointer-events on icon only- Returns a cleanup function that removes the icon
- Clicking the icon calls
onClick(which manually triggers the autofill dropdown even if the field wasn't freshly focused)
The icon must not break field layout: set padding-right on the field to field.style.paddingRight + 26px when injected, restore on cleanup.
3d — Autofill dropdown (upgrade inject.ts)
src/content/inject.ts
Upgrade showAutofillSelector() with:
- Favicon: prepend a 16×16
<img>to each row loadinghttps://icons.duckduckgo.com/ip3/${domain}.ico(extract domain fromentry.url), with a fallback to a generic key SVG on error - Keyboard navigation:
ArrowDown/ArrowUpmoves focus between rows;Enterselects;Escapedismisses;Tabdismisses (don't trap focus) - Repositioning: use a
ResizeObserver+requestAnimationFrameloop to keep the dropdown anchored if the field moves (SPA transitions, sticky headers) - 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 active - TOTP indicator: if an entry has a
totpfield, show a small "⏱" badge on the right side of the row
3e — Trigger loop
src/content/index.ts — replace/extend the current content script:
on document "focusin":
field = getDeepActiveInput()
if field is null or not visible: return
if field type is "password" or ML predicts "username"/"email":
fields = detectLoginFields()
if isLoginForm(fields):
entries = await sendMessage(AUTOFILL_QUERY, { domain: getPageDomain() })
if entries.length > 0:
dismiss = showAutofillSelector(field, entries, async (id) => {
entry = await sendMessage(GET_ENTRY_FOR_FILL, { id })
fillFields(usernameEl, passwordEl, entry.username, entry.password)
if entry.totp:
totpCode = await generateTOTP(entry.totp)
navigator.clipboard.writeText(totpCode)
showToast("TOTP copied · " + totpCode)
})
injectFieldIcon(field, () => dismiss()) // icon re-opens on click
else if isSignupForm(fields):
[existing identity helper logic]
The 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.
3f — Mutation observer (SPA support)
src/content/index.ts
Add setupMutationObserver():
const observer = new MutationObserver(debounce(() => {
// Re-run login field detection on meaningful DOM changes
// Only re-attach if new password or username inputs appear that weren't seen before
}, 120));
observer.observe(document.body, {
childList: true, subtree: true,
attributes: true,
attributeFilter: ["type", "autocomplete", "name", "id", "placeholder", "hidden"]
});
Call 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.
3g — TOTP autofill
If 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:
- After a 400 ms delay (allow page to react to password fill), check if the TOTP field is still empty
- If so, generate the current TOTP code and fill it directly (using
setNativeValue) - Show toast "TOTP autofilled"
Acceptance criteria
- On a standard login form (GitHub, Notion, Linear) the autofill dropdown appears when the username/email field is focused
- Keyboard selection fills both fields without page reload
- After fill, TOTP code is on clipboard with toast confirmation
- On an SPA that renders a login form after navigation, the dropdown appears on the newly-rendered form
- Dropdown appears inside fields rendered in a shadow root
npm run typecheckclean;npm run buildsucceeds;node test-extension.mjspasses
Task 4 — Code splitting (target: v1.2.1)
Why
The 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.
What is heavy
| Module | Why heavy |
|---|---|
| CJKExport + CJKImport | zstd WASM init, argon2 from hash-wasm, diceware wordlist, zxcvbn |
| Export | AES-GCM crypto module, CSV serializer |
| Import | PapaParse, all format parsers, backup decrypt |
| Generator | zxcvbn (already dep but bundled eagerly) |
| onnxruntime-web | ONNX inference (should already be background-only, verify) |
Implementation
src/popup/App.tsx
Replace static imports of heavy views with SolidJS lazy components:
import { lazy } from "solid-js";
const ImportView = lazy(() => import("./views/Import"));
const ExportView = lazy(() => import("./views/Export"));
const CJKExport = lazy(() => import("./views/CJKExport"));
const CJKImport = lazy(() => import("./views/CJKImport"));
const SettingsView = lazy(() => import("./views/Settings"));
const GeneratorView = lazy(() => import("./views/Generator"));
Wrap 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.
src/popup/views/Import.tsx and Export.tsx
Move 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.
src/crypto/*.ts
The 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.
vite.config.ts
Add manual chunk grouping to keep the split predictable:
build: {
target: "esnext",
emptyOutDir: false,
rollupOptions: {
output: {
manualChunks: {
"vendor-solid": ["solid-js"],
"vendor-otp": ["otpauth"],
"vendor-parse": ["papaparse"],
"vendor-qr": ["qrcode"],
}
}
}
}
Acceptance criteria
npm run buildoutput shows no single JS chunk > 350 KB (uncompressed)- The main popup chunk (
index.html-*.js) is < 250 KB - 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)
- All existing Playwright and Vitest tests still pass
Task 5 — Polish pass (target: v1.2.2)
This task has no new features — it is entirely about feel. Every interaction should be tight.
5a — Fuzzy search
src/popup/views/Vault.tsx
Replace the current substring search with a lightweight fuzzy match:
function fuzzyMatch(query: string, target: string): boolean {
query = query.toLowerCase();
target = target.toLowerCase();
let qi = 0;
for (let i = 0; i < target.length && qi < query.length; i++) {
if (target[i] === query[qi]) qi++;
}
return qi === query.length;
}
Match against entry.title + " " + entry.url + " " + entry.username. Sort results so exact prefix matches appear before fuzzy matches. No external dependency.
5b — Inline password strength
src/popup/views/Add.tsx
Under the password input, add a strength bar using zxcvbn (already a dep):
- A 4-segment bar (0–4 score), coloured: red → orange → yellow → green
- Label: "weak" / "fair" / "good" / "strong"
- Only appears once the user has typed ≥ 4 characters
- zxcvbn import:
import { default as zxcvbn } from "zxcvbn"— this module is already in the vendor bundle; no new dependency
5c — Keyboard shortcuts
src/popup/App.tsx
Global keydown listener (when popup is focused):
| Key | Action |
|---|---|
Escape |
Close the topmost open overlay (settings, import, export, CJK, generator, add) |
/ or Ctrl+F |
Focus the search input (from any unlocked non-overlay state) |
Ctrl+L |
Lock vault |
src/popup/views/Entry.tsx
When an entry is open:
| Key | Action |
|---|---|
c |
Copy password |
u |
Copy username |
t |
Copy TOTP (if present) |
e |
Open edit mode |
Escape |
Back to vault list |
5d — Entry favicon
src/popup/views/Vault.tsx and src/popup/views/Entry.tsx
Replace 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.
5e — View transition animations
src/popup/styles/popup.css
Overlays currently appear without animation. Add:
.peach-overlay-view {
animation: overlay-in 180ms cubic-bezier(0.4, 0, 0.2, 1) both;
}
@keyframes overlay-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
Entry detail navigation (list row → detail panel):
.peach-stage-content {
transition: opacity 120ms ease, transform 120ms ease;
}
.peach-stage-content.navigating {
opacity: 0;
transform: translateX(6px);
}
Add/remove .navigating in App.tsx during route transitions using a createEffect on the active entry signal.
5f — Copy button drain animation (complete the website parity)
src/popup/views/Entry.tsx
On successful copy, the copy button should:
- Switch icon to a checkmark SVG
- Show a brief CSS
drainanimation (the website demo has this) - Revert after 1800 ms
This should already be partially present from the v1.0.8 website-parity pass — verify it is wired and working; if not, complete it.
5g — Auto-focus discipline
Every overlay that opens should immediately focus the first interactive element:
- Add entry overlay → focus the title input
- Search / vault list → focus the search input
- CJK import → focus the passphrase input
- QR overlay → focus the close button (nothing to type)
Use SolidJS onMount with el.focus().
Acceptance criteria
- Typing a partial entry name with gaps finds the entry via fuzzy match
- Password strength bar appears while typing in Add view
Escapecloses the topmost overlay from any state/focuses search from the vault listccopies password on entry detail- Entry list rows show site favicons
- Overlays animate in smoothly; no visible layout jump
npm run typecheckclean;npm testpasses;npm run buildsucceeds;node test-extension.mjsconfirms vault persistence
Task 6 — Final verification gate (target: v1.2.2 — same as polish)
After all tasks are complete, run the full suite in order. All four must pass before declaring the spec complete.
npm run typecheck
npm test
npm run build
DISPLAY=:99 node test-extension.mjs
Expected output from smoke test:
Extension ID: <id>
Popup title: Peach
Entry created
Entry visible after save: true
Create vault visible after reopen: false
Unlock visible after reopen: false (or true if lockOnClose is on)
Entry still present after unlock: true
Done.
CONTINUITY.md must be updated with a [OUTCOMES] entry covering every task completed.
Appendix A — ML feature vector reference
The ONNX model at public/models/form_detector.onnx expects a float32[1,45] tensor. Features in order:
| Index | Feature | Notes |
|---|---|---|
| 0–7 | type_text/email/password/tel/number/search/url/other |
one-hot |
| 8–14 | auto_username/email/current-password/new-password/one-time-code/off/other |
one-hot |
| 15–18 | name_has_user/login/email/pass |
regex matchScore |
| 19 | name_length |
/ 50 |
| 20–23 | id_has_user/login/email/pass |
regex matchScore |
| 24 | id_length |
/ 50 |
| 25–27 | placeholder_has_user/email/pass |
regex matchScore |
| 28 | placeholder_length |
/ 100 |
| 29–31 | aria_label_has_user/email/pass |
regex matchScore |
| 32 | aria_label_length |
/ 100 |
| 33–35 | parent_is_form/div/section |
one-hot on closest block parent |
| 36 | sibling_count |
/ 10 |
| 37 | has_password_sibling |
bool |
| 38 | has_email_sibling |
bool |
| 39 | form_has_submit |
bool |
| 40 | form_action_has_login |
bool |
| 41 | is_required |
bool |
| 42 | has_placeholder |
bool |
| 43 | has_aria_label |
bool |
| 44 | inputmode_numeric |
bool |
Output: softmax over ['username', 'password', 'email', 'totp', 'none']. Argmax taken.
Appendix B — Legacy Peach JSON field mapping
| Legacy field | VaultEntry field |
Notes |
|---|---|---|
entry.name |
title |
|
entry.login.urls[0] |
url |
First URL only |
entry.login.username |
username |
|
entry.login.password |
password |
|
entry.login.totp.secret |
totp |
Drop algorithm/digits/period |
entry.login.notes |
notes |
If present |
entry.created |
createdAt |
|
entry.modified |
modifiedAt |
|
entry.trashedAt |
deletedAt |
|
entry.login.passkey |
— | Drop silently |
entry.login.customFields |
— | Drop silently |
entry.tags / favorite |
— | Drop silently |
type !== "login" |
— | Skip, count as skipped |
Appendix C — QR size sanity
| Vault size | CJK phrase length | QR version needed | Fits? |
|---|---|---|---|
| 10 entries | ~120 chars | Version 10 (ECC M) | ✓ |
| 50 entries | ~450 chars | Version 25 (ECC M) | ✓ |
| 100 entries | ~827 chars | Version 33 (ECC M) | ✓ |
| 500 entries | ~3,500 chars | Version 40 (ECC L) | ✓ |
| 1,000 entries | ~6,500 chars | > Version 40 | ✗ — fall back to CJK text display only, hide "Show as QR" button |
When 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."
Appendix D — What "complete" means
A password manager extension is production-complete when a user can:
- First run — create a vault in < 30 s, feel secure doing it
- Daily use — open any login page, see the credential dropdown appear, click, done
- TOTP — never manually open an authenticator app; code is on clipboard automatically
- Recover — restore their vault from a CJK phrase on a new device in < 2 minutes
- Migrate — import from any mainstream manager (Bitwarden, 1Password, Chrome, LastPass, Proton Pass, legacy Peach) without losing data
- Trust — zero plaintext password ever touches the network; breach status is visible at a glance
After v1.2.2 Peach satisfies all six. That is the bar.