peach_completion_spec.md

26.2 KB · raw

rendered · source

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

Known gaps (drives this spec)


Version bump rule

Bump version in package.json, manifest.json, and package-lock.json after every task. Use semantic versioning:


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

  1. Add "peach-legacy-json" to the ImportFormat union type.
  2. In detectImportFormat(), before the csv fallback, add:
    if (envelope?.exportFormat === "peach-vault") return "peach-legacy-json";
    
  3. 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 ?? undefined
      • notes ← entry.login.notes ?? undefined
      • createdAt ← 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.
  4. Wire into the switch in parseImportSource() with label: "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:

Acceptance criteria


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:

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:

Acceptance criteria


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:

  1. Content script observes field focus events (including inside shadow roots via deep traversal)
  2. For any password-type field or ML-classified username/email field in a login context, fire AUTOFILL_QUERY with the page's registrable domain
  3. Background returns matching vault entries
  4. Dropdown appears anchored below the focused field (closed shadow DOM)
  5. User selects an entry (click or keyboard) → GET_ENTRY_FOR_FILL → fillFields()
  6. If entry has totp, copy to clipboard and show toast "TOTP copied"
  7. 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[]:

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:

Add isSignupForm(fields: DetectedField[]): boolean (update existing if present):

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:

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:

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:

Acceptance criteria


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


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):

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:

  1. Switch icon to a checkmark SVG
  2. Show a brief CSS drain animation (the website demo has this)
  3. 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:

Use SolidJS onMount with el.focus().

Acceptance criteria


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:

  1. First run — create a vault in < 30 s, feel secure doing it
  2. Daily use — open any login page, see the credential dropdown appear, click, done
  3. TOTP — never manually open an authenticator app; code is on clipboard automatically
  4. Recover — restore their vault from a CJK phrase on a new device in < 2 minutes
  5. Migrate — import from any mainstream manager (Bitwarden, 1Password, Chrome, LastPass, Proton Pass, legacy Peach) without losing data
  6. 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.