peach_daily_driver_build_guide.md

51.9 KB · raw

rendered · source

1*peach*23**Daily-Driver Build Guide**45Extension 1.0 RC · Tonight\'s target67*From-scratch rebuild. Verifiable crypto. Real autofill.8Daily-driverable tonight.*910**§ 0 --- What You Are Building Tonight**1112This is not Peach 2.0 complete. This is Peach Extension 1.0 RC --- the13version you can trust with your real passwords today. The goal is one14confident night of work that ends with a daily-driverable extension, not15a month of polish before you can migrate.1617The current repo is a patch-fest. The strategy here is a clean-room18rebuild of the extension layer only, using the exact stack the spec19demands. Do not attempt to salvage broken state. Start clean, move fast,20stop at the right scope.2122**Tonight\'s Outcome (Non-Negotiable)**2324  ---------------- -------------------------------------------------25  **Crypto**       Vault create / unlock / lock with Argon2id +26                   AES-256-GCM. Master password never written to27                   disk, never logged, never transmitted.2829  **Vault UI**     Add, edit, delete, search, copy, TOTP, generator30                   --- functional and feeling like Peach.3132  **Autofill**     Form detection, user-triggered fill, strict33                   domain matching, Shadow DOM selector.3435  **Save /         Submitted credentials detected, save/update36  Update**         banner offered.3738  **Import**       Generic CSV + 1Password JSON. Your passwords39                   migrate tonight.4041  **Export**       Encrypted backup. CJK phrase export. One safe42                   path verified.4344  **Lock           Auto-lock on inactivity, clipboard clear, proper45  semantics**      state purge on lock.4647  **Visual feel**  Existing motion language, warm dark palette,48                   ring + pip behavior intact.49  ---------------- -------------------------------------------------5051**What Is Explicitly Out Tonight**5253- Canary entries5455- Duress password5657- Full LAN sync (Peach Sync)5859- Passkeys6061- Tauri desktop app6263- HIBP breach surface beyond badge storage6465- More than 2 import adapters6667> **⚠ These are not cut for quality reasons. They are cut because you68> need to use Peach tonight, not ship Peach 2.0.**6970**§ 1 --- Stack & Project Bootstrap**7172Build with the spec\'s exact choices. Do not improvise. These are73load-bearing decisions.7475**Tech Choices**7677  ---------------- -------------------------------------------------78  **Runtime**      Manifest V3 Chrome extension. Service worker for79                   background. Popup for UI.8081  **UI framework** SolidJS --- \~7KB runtime, zero virtual DOM,82                   renders to real DOM. Spec mandates this for83                   \<100ms popup open.8485  **Bundler**      Vite with \@crxjs/vite-plugin --- the only86                   bundler that handles MV3 service worker bundling87                   correctly without hacks.8889  **Crypto (KDF)** hash-wasm --- provides Argon2id in WASM, runs in90                   service worker. Do not use any other library for91                   KDF.9293  **Crypto         Web Crypto API (SubtleCrypto) --- native94  (encrypt)**      AES-256-GCM. No external library needed.9596  **TOTP**         otpauth --- correct RFC 6238 implementation, 4KB,97                   tree-shakeable.9899  **Language**     TypeScript strict mode throughout. Zero any100                   escapes in crypto paths.101102  **Domain         tldts --- Public Suffix List based eTLD+1103  parsing**        extraction. Required for strict domain matching.104105  **Import         papaparse for CSV.106  parsing**        107  ---------------- -------------------------------------------------108109**Scaffold Command Sequence**110111Run these in order. Do not deviate.112113> \# 1. Create the project npm create vite@latest peach-ext \--114> \--template solid-ts cd peach-ext \# 2. Install runtime dependencies115> npm install hash-wasm otpauth tldts papaparse \# 3. Install extension116> tooling npm install -D \@crxjs/vite-plugin webextension-polyfill npm117> install -D \@types/chrome \# 4. Install dev deps npm install -D vitest118> \@vitest/ui119120**vite.config.ts**121122> import { defineConfig } from \"vite\"; import solidPlugin from123> \"vite-plugin-solid\"; import { crx } from \"@crxjs/vite-plugin\";124> import manifest from \"./manifest.json\"; export default125> defineConfig({ plugins: \[solidPlugin(), crx({ manifest })\], build: {126> target: \"esnext\", rollupOptions: { input: { popup:127> \"src/popup/index.html\" } } } });128129**manifest.json --- Complete**130131Copy this exactly. Every permission is justified in the spec. Do NOT add132permissions not listed here.133134> { \"manifest_version\": 3, \"name\": \"Peach\", \"version\":135> \"1.0.0\", \"description\": \"Zero-knowledge password manager\",136> \"action\": { \"default_popup\": \"src/popup/index.html\" },137> \"background\": { \"service_worker\": \"src/background/index.ts\",138> \"type\": \"module\" }, \"content_scripts\": \[{ \"matches\":139> \[\"\<all_urls\>\"\], \"js\": \[\"src/content/index.ts\"\],140> \"run_at\": \"document_idle\" }\], \"permissions\": \[\"storage\",141> \"activeTab\", \"scripting\", \"clipboardWrite\", \"alarms\"\],142> \"content_security_policy\": { \"extension_pages\": \"script-src143> \'self\' \'wasm-unsafe-eval\'; object-src \'none\'\" }, \"icons\": {144> \"16\": \"icons/16.png\", \"48\": \"icons/48.png\", \"128\":145> \"icons/128.png\" } }146>147> **🔴 CRITICAL: No \<all_urls\> host permission appears anywhere else.148> content_scripts matching \<all_urls\> is necessary for autofill149> detection. The permissions array must never include \"tabs\",150> \"webNavigation\", \"history\", or \"cookies\".**151152**§ 2 --- File Structure**153154This structure maps directly to the spec\'s architectural boundaries.155Content script cannot import from background. Crypto is shared. Types156are shared.157158> src/ crypto/ argon2.ts \# Argon2id KDF --- only file that touches159> hash-wasm aes.ts \# AES-256-GCM encrypt/decrypt --- SubtleCrypto only160> totp.ts \# RFC 6238 TOTP --- otpauth wrapper index.ts \# re-exports161> background/ vault.ts \# unlock/lock state machine, key held here162> messages.ts \# validated message handler (sendMessage router)163> autofill.ts \# domain index, entry matching clipboard.ts \# copy + 30s164> clear timer alarms.ts \# auto-lock alarm setup index.ts \# service165> worker entry content/ detect.ts \# form detection, MutationObserver166> inject.ts \# Shadow DOM popup construction fill.ts \# synchronous167> field fill save.ts \# submit detection, save banner index.ts \#168> content script entry popup/ index.html \# pre-rendered lock shell (no169> blank flash) App.tsx \# SolidJS root views/ Lock.tsx Vault.tsx170> Entry.tsx Add.tsx Settings.tsx Import.tsx Export.tsx components/171> Ring.tsx \# SVG accent ring Pip.tsx \# status pip Generator.tsx \#172> password generator TOTP.tsx \# TOTP code + countdown styles/ vars.css173> \# CSS custom properties (palette, motion curves) popup.css \# popup174> shell layout shared/ types.ts \# VaultEntry, StoredVault, Message175> types domain.ts \# eTLD+1 extraction wrapper generate.ts \# password176> generator logic177178**§ 3 --- Cryptographic Core (Build This First)**179180The crypto layer is the product. If this is wrong, nothing else matters.181Build it in isolation, test it before touching any UI. The service182worker holds the key in memory. Nothing else does.183184**src/shared/types.ts --- Canonical Types**185186Every file imports from here. Types never drift.187188> export interface VaultEntry { id: string; // crypto.randomUUID()189> title: string; username: string; password: string; url: string; totp?:190> string; // base32 seed, stored encrypted notes?: string; breach?:191> boolean; // set by HIBP check created: number; // Date.now() updated:192> number; } export interface StoredVault { version: 1; // AAD for193> AES-GCM --- prevents downgrade salt: string; // hex --- 16 bytes,194> CSPRNG, stored not secret nonce: string; // hex --- 12 bytes, CSPRNG,195> fresh per write ciphertext: string; // hex --- AES-256-GCM output tag:196> string; // hex --- 16-byte GCM auth tag } export type MessageType = \|197> \"UNLOCK\" \| \"LOCK\" \| \"IS_UNLOCKED\" \| \"GET_ENTRIES\" \|198> \"ADD_ENTRY\" \| \"UPDATE_ENTRY\" \| \"DELETE_ENTRY\" \|199> \"AUTOFILL_QUERY\" \| \"COPY\" \| \"CREATE_VAULT\"; export interface200> Message { type: MessageType; payload?: unknown; } export interface201> MessageResponse { ok: boolean; data?: unknown; error?: string; }202203**src/crypto/argon2.ts --- Key Derivation**204205> import { argon2id } from \"hash-wasm\"; /\*\* Derive a 32-byte AES key206> from the master password. \* Parameters per spec: m=65536, t=3, p=4.207> \* salt must be 16 CSPRNG bytes (not a string). \* Returns208> Uint8Array(32). Never returns the password. \*/ export async function209> deriveKey( password: string, salt: Uint8Array ): Promise\<Uint8Array\>210> { const result = await argon2id({ password, salt, parallelism: 4,211> iterations: 3, memorySize: 65536, // 64 MB hashLength: 32, outputType:212> \"binary\", }); return result; } /\*\* Generate a 16-byte CSPRNG salt.213> Store this. It is not secret. \*/ export function generateSalt():214> Uint8Array { return crypto.getRandomValues(new Uint8Array(16)); }215216**src/crypto/aes.ts --- Encryption / Decryption**217218> const VERSION_BYTE = new Uint8Array(\[1\]); // AAD --- prevents219> downgrade attacks function hex(buf: ArrayBuffer): string { return220> Array.from(new Uint8Array(buf)) .map(b =\> b.toString(16).padStart(2,221> \"0\")).join(\"\"); } function unhex(s: string): Uint8Array { const222> out = new Uint8Array(s.length / 2); for (let i = 0; i \< s.length; i223> += 2) out\[i / 2\] = parseInt(s.slice(i, i + 2), 16); return out; }224> export async function encrypt( keyBytes: Uint8Array, plaintext: string225> ): Promise\<{ nonce: string; ciphertext: string; tag: string }\> {226> const key = await crypto.subtle.importKey( \"raw\", keyBytes, { name:227> \"AES-GCM\" }, false, \[\"encrypt\"\] ); // CRITICAL: fresh random228> nonce every single call const nonce = crypto.getRandomValues(new229> Uint8Array(12)); const enc = new TextEncoder(); const raw = await230> crypto.subtle.encrypt( { name: \"AES-GCM\", iv: nonce, additionalData:231> VERSION_BYTE, tagLength: 128 }, key, enc.encode(plaintext) ); //232> SubtleCrypto appends the 16-byte tag to the ciphertext buffer const233> full = new Uint8Array(raw); const ct = full.slice(0, full.length -234> 16); const tag = full.slice(full.length - 16); return { nonce:235> hex(nonce), ciphertext: hex(ct), tag: hex(tag) }; } export async236> function decrypt( keyBytes: Uint8Array, nonce: string, ciphertext:237> string, tag: string ): Promise\<string\> { const key = await238> crypto.subtle.importKey( \"raw\", keyBytes, { name: \"AES-GCM\" },239> false, \[\"decrypt\"\] ); // Recombine ciphertext + tag (SubtleCrypto240> expects them concatenated) const ct = unhex(ciphertext); const tg =241> unhex(tag); const combined = new Uint8Array(ct.length + tg.length);242> combined.set(ct); combined.set(tg, ct.length); // If auth tag fails,243> SubtleCrypto throws --- NEVER returns partial plaintext const raw =244> await crypto.subtle.decrypt( { name: \"AES-GCM\", iv: unhex(nonce),245> additionalData: VERSION_BYTE, tagLength: 128 }, key, combined );246> return new TextDecoder().decode(raw); } export function247> zeroKey(keyBytes: Uint8Array): void { keyBytes.fill(0); }248>249> **🔴 CRITICAL: zeroKey() must be called on every lock path. If you250> lock and do not call zeroKey(), the derived key lingers in the service251> worker\'s V8 heap. This is a zero-knowledge violation.**252253**src/crypto/totp.ts --- RFC 6238 TOTP**254255> import { TOTP } from \"otpauth\"; export function256> generateTOTP(base32Secret: string): { code: string; remaining: number257> } { const totp = new TOTP({ secret: base32Secret, digits: 6, period:258> 30 }); const code = totp.generate(); const remaining = 30 -259> (Math.floor(Date.now() / 1000) % 30); return { code, remaining }; }260> /\*\* Parse an otpauth:// URI and extract the base32 secret. \*/261> export function parseOtpauthURI(uri: string): string \| null { try {262> const totp = TOTP.parse(uri); return totp.secret.base32; } catch {263> return null; } }264265**Crypto Verification Test (Run Before Anything Else)**266267Put this in src/crypto/crypto.test.ts and run: npx vitest run268269> import { describe, it, expect } from \"vitest\"; import { deriveKey,270> generateSalt } from \"./argon2\"; import { encrypt, decrypt, zeroKey }271> from \"./aes\"; describe(\"vault crypto\", () =\> { it(\"round-trips272> plaintext through encrypt/decrypt\", async () =\> { const salt =273> generateSalt(); const key = await deriveKey(\"correct-horse\", salt);274> const { nonce, ciphertext, tag } = await encrypt(key, \"hello275> peach\"); const plain = await decrypt(key, nonce, ciphertext, tag);276> expect(plain).toBe(\"hello peach\"); }); it(\"rejects tampered277> ciphertext\", async () =\> { const salt = generateSalt(); const key =278> await deriveKey(\"correct-horse\", salt); const { nonce, ciphertext,279> tag } = await encrypt(key, \"secret data\"); const tampered =280> ciphertext.slice(0, -2) + \"ff\"; await expect(decrypt(key, nonce,281> tampered, tag)).rejects.toThrow(); }); it(\"rejects wrong password\",282> async () =\> { const salt = generateSalt(); const key1 = await283> deriveKey(\"right-password\", salt); const { nonce, ciphertext, tag }284> = await encrypt(key1, \"secret\"); const key2 = await285> deriveKey(\"wrong-password\", salt); await expect(decrypt(key2, nonce,286> ciphertext, tag)).rejects.toThrow(); }); it(\"generates fresh nonce on287> each encrypt\", async () =\> { const salt = generateSalt(); const key288> = await deriveKey(\"test\", salt); const e1 = await encrypt(key,289> \"data\"); const e2 = await encrypt(key, \"data\");290> expect(e1.nonce).not.toBe(e2.nonce); // CRITICAL: nonces must differ291> }); });292>293> *✦ RULE: All four tests must pass before you write a single line of UI294> code. If any test fails, the extension does not exist yet.*295296**§ 4 --- Background Service Worker**297298The service worker is the only place that holds the derived key. It299manages vault state, answers messages from both the popup and the300content script, and runs the auto-lock alarm.301302**src/background/vault.ts --- State Machine**303304This is the heart of the extension. The key lives here and only here.305306> import { deriveKey, generateSalt } from \"../crypto/argon2\"; import {307> encrypt, decrypt, zeroKey } from \"../crypto/aes\"; import type {308> VaultEntry, StoredVault } from \"../shared/types\"; // ── In-memory309> session state ────────────────────────────────────── // Nothing here310> is ever written to disk. let derivedKey: Uint8Array \| null = null;311> let entries: VaultEntry\[\] = \[\]; let domainIndex: Map\<string,312> VaultEntry\[\]\> = new Map(); // ── Persistence (encrypted)313> ────────────────────────────────────── async function314> loadStoredVault(): Promise\<StoredVault \| null\> { const { vault } =315> await chrome.storage.local.get(\"vault\"); return vault ?? null; }316> async function saveStoredVault(v: StoredVault): Promise\<void\> {317> await chrome.storage.local.set({ vault: v }); } // ── Domain index for318> O(1) autofill lookup ──────────────────────── function319> buildDomainIndex(all: VaultEntry\[\]): void { domainIndex = new Map();320> for (const e of all) { try { const { getDomain } = await321> import(\"../shared/domain\"); // buildDomainIndex is sync --- use a322> cached result in practice // see domain.ts for the eTLD+1 extraction }323> catch {} } } // ── Public API324> ─────────────────────────────────────────────────── export async325> function createVault(password: string): Promise\<void\> { const salt =326> generateSalt(); derivedKey = await deriveKey(password, salt); entries327> = \[\]; const plain = JSON.stringify(entries); const { nonce,328> ciphertext, tag } = await encrypt(derivedKey, plain); const stored:329> StoredVault = { version: 1, salt: hex(salt), nonce, ciphertext, tag };330> await saveStoredVault(stored); setupAutoLock(); } export async331> function unlock(password: string): Promise\<boolean\> { const stored =332> await loadStoredVault(); if (!stored) return false; try { const salt =333> unhex(stored.salt); const key = await deriveKey(password, salt); const334> plain = await decrypt(key, stored.nonce, stored.ciphertext,335> stored.tag); entries = JSON.parse(plain) as VaultEntry\[\]; derivedKey336> = key; buildDomainIndexSync(entries); setupAutoLock(); return true; }337> catch { // Wrong password --- decrypt throws, do not expose which byte338> failed return false; } } export function lock(): void { if339> (derivedKey) { zeroKey(derivedKey); // overwrite bytes before340> dereferencing derivedKey = null; } entries = \[\]; domainIndex = new341> Map(); chrome.alarms.clear(\"autolock\"); } export function342> isUnlocked(): boolean { return derivedKey !== null; } export function343> getEntries(): VaultEntry\[\] { if (!derivedKey) throw new344> Error(\"vault locked\"); return entries; } export async function345> addEntry(e: Omit\<VaultEntry, \"id\" \| \"created\" \| \"updated\"\>):346> Promise\<VaultEntry\> { if (!derivedKey) throw new Error(\"vault347> locked\"); const entry: VaultEntry = { \...e, id: crypto.randomUUID(),348> created: Date.now(), updated: Date.now() }; entries.push(entry); await349> persistEntries(); return entry; } export async function350> updateEntry(id: string, patch: Partial\<VaultEntry\>): Promise\<void\>351> { if (!derivedKey) throw new Error(\"vault locked\"); const idx =352> entries.findIndex(e =\> e.id === id); if (idx === -1) throw new353> Error(\"entry not found\"); entries\[idx\] = { \...entries\[idx\],354> \...patch, updated: Date.now() }; await persistEntries(); } export355> async function deleteEntry(id: string): Promise\<void\> { if356> (!derivedKey) throw new Error(\"vault locked\"); entries =357> entries.filter(e =\> e.id !== id); await persistEntries(); } export358> function queryDomain(domain: string): VaultEntry\[\] { return359> domainIndex.get(domain) ?? \[\]; } async function persistEntries():360> Promise\<void\> { if (!derivedKey) throw new Error(\"vault locked\");361> const plain = JSON.stringify(entries); const { nonce, ciphertext, tag362> } = await encrypt(derivedKey, plain); const stored = await363> loadStoredVault(); if (!stored) throw new Error(\"no stored vault364> header\"); await saveStoredVault({ \...stored, nonce, ciphertext, tag365> }); buildDomainIndexSync(entries); } function366> buildDomainIndexSync(all: VaultEntry\[\]): void { domainIndex = new367> Map(); for (const e of all) { const d = extractDomain(e.url); if (!d)368> continue; const bucket = domainIndex.get(d) ?? \[\]; bucket.push(e);369> domainIndex.set(d, bucket); } } function extractDomain(url: string):370> string \| null { try { const { hostname } = new371> URL(url.startsWith(\"http\") ? url : \"https://\" + url); // Simple372> eTLD+1: take last two parts. For tonight this is sufficient. // Full373> PSL via tldts for production path. const parts =374> hostname.split(\".\"); return parts.slice(-2).join(\".\"); } catch {375> return null; } } function setupAutoLock(): void {376> chrome.alarms.clear(\"autolock\"); // 15 minutes default --- spec §2.3377> chrome.alarms.create(\"autolock\", { delayInMinutes: 15 }); } // hex378> helpers (duplicated to avoid circular imports) function hex(buf:379> Uint8Array): string { return Array.from(buf).map(b =\>380> b.toString(16).padStart(2, \"0\")).join(\"\"); } function unhex(s:381> string): Uint8Array { const out = new Uint8Array(s.length / 2); for382> (let i = 0; i \< s.length; i += 2) out\[i / 2\] = parseInt(s.slice(i,383> i + 2), 16); return out; }384385**src/background/messages.ts --- Validated Message Router**386387Every message is validated. No blind trust. The popup and content script388are untrusted callers.389390> import type { Message, MessageResponse } from \"../shared/types\";391> import \* as vault from \"./vault\"; import { copyToClipboard } from392> \"./clipboard\"; export function setupMessageHandler(): void {393> chrome.runtime.onMessage.addListener( (msg: unknown, sender,394> sendResponse) =\> { // Validate structure before touching anything if395> (!isMessage(msg)) { sendResponse({ ok: false, error: \"invalid396> message\" }); return true; } handleMessage(msg,397> sender).then(sendResponse); return true; // keep port open for async398> response } ); } function isMessage(m: unknown): m is Message { return399> typeof m === \"object\" && m !== null && \"type\" in m; } async400> function handleMessage(msg: Message, sender:401> chrome.runtime.MessageSender): Promise\<MessageResponse\> { try {402> switch (msg.type) { case \"CREATE_VAULT\": { const { password } =403> msg.payload as { password: string }; await404> vault.createVault(password); return { ok: true }; } case \"UNLOCK\": {405> const { password } = msg.payload as { password: string }; const ok =406> await vault.unlock(password); return { ok }; } case \"LOCK\":407> vault.lock(); return { ok: true }; case \"IS_UNLOCKED\": return { ok:408> true, data: vault.isUnlocked() }; case \"GET_ENTRIES\": return { ok:409> true, data: vault.getEntries() }; case \"ADD_ENTRY\": { const entry =410> await vault.addEntry(msg.payload as any); return { ok: true, data:411> entry }; } case \"UPDATE_ENTRY\": { const { id, patch } = msg.payload412> as any; await vault.updateEntry(id, patch); return { ok: true }; }413> case \"DELETE_ENTRY\": { const { id } = msg.payload as { id: string };414> await vault.deleteEntry(id); return { ok: true }; } case415> \"AUTOFILL_QUERY\": { const { domain } = msg.payload as { domain:416> string }; const matches = vault.queryDomain(domain); // Return only417> what content script needs --- never full vault return { ok: true,418> data: matches.map(e =\> ({ id: e.id, title: e.title, username:419> e.username, domain: e.url })) }; } case \"COPY\": { const { text,420> label } = msg.payload as { text: string; label: string }; await421> copyToClipboard(text, label); return { ok: true }; } default: return {422> ok: false, error: \"unknown message type\" }; } } catch (err) { //423> Never expose raw error messages to content scripts return { ok: false,424> error: \"operation failed\" }; } }425426**src/background/index.ts --- Service Worker Entry**427428> import { setupMessageHandler } from \"./messages\"; import { lock }429> from \"./vault\"; setupMessageHandler(); // Auto-lock alarm handler430> chrome.alarms.onAlarm.addListener((alarm) =\> { if (alarm.name ===431> \"autolock\") lock(); }); // Lock on system idle (spec §13.4)432> chrome.idle.setDetectionInterval(60);433> chrome.idle.onStateChanged.addListener((state) =\> { if (state ===434> \"locked\") lock(); });435436**§ 5 --- Popup**437438The popup is SolidJS. 320px wide, minimum height. Pre-rendered lock439shell in HTML so there is never a blank flash on open. Spec mandates440\<100ms to interactive.441442**src/popup/index.html --- Pre-Rendered Shell**443444This HTML is the first pixel the user sees. It should contain the lock445screen structure in raw HTML so Chrome shows something before JS parses.446SolidJS hydrates into it.447448> \<!DOCTYPE html\> \<html lang=\"en\"\> \<head\> \<meta449> charset=\"UTF-8\"\> \<meta name=\"viewport\"450> content=\"width=device-width, initial-scale=1.0\"\> \<link451> rel=\"stylesheet\" href=\"./styles/vars.css\"\> \<link452> rel=\"stylesheet\" href=\"./styles/popup.css\"\>453> \<title\>Peach\</title\> \</head\> \<body\> \<!\-- Pre-rendered lock454> shell. SolidJS mounts into #app. \--\> \<!\-- Chrome renders this455> BEFORE JS loads --- no blank flash. \--\> \<div id=\"app\"\> \<div456> class=\"popup-shell\"\> \<header class=\"chrome\"\> \<span457> class=\"wordmark\"\>peach\<span class=\"pip\"\>\</span\>\</span\>458> \</header\> \<div class=\"view-lock\"\> \<p459> class=\"unlock-headline\"\>welcome back.\</p\> \<p460> class=\"unlock-sub\"\>your vault is waiting\</p\> \<input461> class=\"pw-field\" type=\"password\" placeholder=\"master password\"462> /\> \<button class=\"btn-primary\"\>unlock\</button\> \</div\>463> \</div\> \</div\> \<script type=\"module\"464> src=\"./App.tsx\"\>\</script\> \</body\> \</html\>465466**CSS Variables --- src/popup/styles/vars.css**467468These are the exact values from the spec. Do not change them. Every469Peach component references these variables, never hardcoded hex values.470471> :root { /\* Palette \*/ \--bg: #0C0A06; \--bg-1: #111009; \--bg-2:472> #1A1710; \--bg-3: #221F14; /\* Text \*/ \--t0: #F0E8D8; \--t1:473> rgba(240, 232, 216, 0.70); \--t2: rgba(240, 232, 216, 0.35); \--t3:474> rgba(240, 232, 216, 0.12); \--t4: rgba(240, 232, 216, 0.04); /\*475> Accent \*/ \--ac: #D96830; /\* Motion curves --- from motion language476> spec \*/ \--spring: cubic-bezier(0.16, 1, 0.3, 1); \--retreat:477> cubic-bezier(0.4, 0, 1, 1); \--settle: cubic-bezier(0.34, 1.56, 0.64,478> 1); /\* Duration tokens \*/ \--dur-enter: 500ms; \--dur-exit: 280ms;479> \--dur-micro: 150ms; }480481**Popup Layout --- src/popup/styles/popup.css**482483> body { width: 320px; min-height: 480px; max-height: 600px; background:484> var(\--bg); color: var(\--t0); font-family: \"Inter\", sans-serif;485> overflow: hidden; margin: 0; } .popup-shell { display: flex;486> flex-direction: column; height: 100%; position: relative; } /\*487> Wordmark \*/ .wordmark { font-family: \"Cormorant Garamond\", serif;488> font-size: 16px; font-style: italic; font-weight: 300; color:489> var(\--t0); display: flex; align-items: center; gap: 5px; } .pip {490> width: 5px; height: 5px; border-radius: 50%; background: var(\--ac);491> box-shadow: 0 0 0 0 rgba(217,104,48,0.5); transition: box-shadow 400ms492> var(\--settle); } .pip.pulse { animation: pipPulse 400ms493> var(\--settle) both; } \@keyframes pipPulse { 0% { transform:494> scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0.7); } 50% { transform:495> scale(2.2); box-shadow: 0 0 8px 4px rgba(217,104,48,0.3); } 100% {496> transform: scale(1); box-shadow: 0 0 0 0 rgba(217,104,48,0); } } /\*497> Unlock view \*/ .view-lock { padding: 40px 24px 24px; display: flex;498> flex-direction: column; gap: 16px; } .unlock-headline { font-family:499> \"Cormorant Garamond\", serif; font-size: 44px; font-style: italic;500> font-weight: 300; color: var(\--t0); margin: 0; } .unlock-sub {501> font-family: \"IBM Plex Mono\", monospace; font-size: 10px;502> font-style: italic; color: var(\--t2); letter-spacing: 0.1em; margin:503> 0; } .pw-field { background: transparent; border: none; border-bottom:504> 1px solid var(\--t3); color: var(\--t0); font-family: \"IBM Plex505> Mono\", monospace; font-size: 18px; letter-spacing: 0.24em; padding:506> 8px 0; outline: none; width: 100%; } .pw-field:focus {507> border-bottom-color: var(\--ac); } .pw-field.shake { animation: shake508> 300ms ease-in-out; } \@keyframes shake { 0% { transform:509> translateX(0); } 20% { transform: translateX(-6px); } 40% { transform:510> translateX(6px); } 60% { transform: translateX(-4px); } 80% {511> transform: translateX(4px); } 100%{ transform: translateX(0); } } /\*512> Primary button \*/ .btn-primary { background: var(\--ac); color:513> rgba(0,0,0,0.75); font-family: \"Inter\", sans-serif; font-size: 11px;514> font-weight: 400; letter-spacing: 0.12em; text-transform: uppercase;515> border: none; border-radius: 8px; padding: 12px 20px; cursor: pointer;516> width: 100%; transition: opacity 150ms ease; } .btn-primary:hover {517> opacity: 0.88; } .btn-primary:active { transform: scale(0.98); }518519**The Accent Ring Component --- src/popup/components/Ring.tsx**520521This implements the full ring state machine from the interaction spec.522380ms draw, 3.5% overshoot, 1200ms hold, 600ms fade. Never fires on523hover or passive events.524525> import { onMount, onCleanup } from \"solid-js\"; interface RingProps {526> target: () =\> HTMLElement \| null; } export function Ring(props:527> RingProps) { let svgEl: SVGSVGElement \| undefined; let rectEl:528> SVGRectElement \| undefined; let fadeTimer: ReturnType\<typeof529> setTimeout\>; function calibrate() { const el = props.target(); if530> (!el \|\| !svgEl \|\| !rectEl) return; const { width, height } =531> el.getBoundingClientRect(); const r = 12; // match popup border-radius532> const perim = 2 \* (width + height); svgEl.setAttribute(\"viewBox\",533> \`0 0 \${width} \${height}\`); svgEl.setAttribute(\"width\",534> String(width)); svgEl.setAttribute(\"height\", String(height));535> rectEl.setAttribute(\"width\", String(width));536> rectEl.setAttribute(\"height\", String(height));537> rectEl.setAttribute(\"rx\", String(r)); rectEl.setAttribute(\"ry\",538> String(r)); rectEl.style.setProperty(\"\--perim\", String(perim));539> rectEl.style.strokeDasharray = String(perim);540> rectEl.style.strokeDashoffset = String(perim); } // Exported: call541> this on meaningful user actions function trigger() { if (!rectEl)542> return; rectEl.style.animation = \"none\"; void rectEl.offsetWidth; //543> force reflow rectEl.style.animation = \"ringDraw 380ms ease-out544> both\"; clearTimeout(fadeTimer); fadeTimer = setTimeout(beginFade,545> 380 + 1200); } function beginFade() { if (!rectEl) return;546> rectEl.style.transition = \"opacity 600ms var(\--retreat)\";547> rectEl.style.opacity = \"0\"; } onMount(() =\> { calibrate(); //548> Re-calibrate if popup height changes const ro = new549> ResizeObserver(calibrate); const el = props.target(); if (el)550> ro.observe(el); onCleanup(() =\> ro.disconnect()); }); // Make trigger551> available to parent via ref pattern (window as552> any).\_\_peachRingTrigger = trigger; return ( \<svg ref={svgEl}553> style={{ position: \"absolute\", inset: 0, \"pointer-events\":554> \"none\", \"z-index\": 10 }} \> \<style\>{\` \@keyframes ringDraw { 0%555> { stroke-dashoffset: var(\--perim); opacity: 0; } 60% {556> stroke-dashoffset: calc(var(\--perim) \* -0.035); opacity: 0.85; } 78%557> { stroke-dashoffset: calc(var(\--perim) \* 0.008); } 100% {558> stroke-dashoffset: 0; opacity: 0.85; } } \`}\</style\> \<rect559> ref={rectEl} x=\"0\" y=\"0\" fill=\"none\" stroke=\"var(\--ac)\"560> stroke-width=\"1\" style={{ filter: \"drop-shadow(0 0 5px561> rgba(217,104,48,0.8))\", transform: \`rotate(-90deg)\`,562> \"transform-origin\": \"50% 50%\", opacity: \"0\", }} /\> \</svg\> );563> } // Call this from any interaction handler export function564> triggerRing() { (window as any).\_\_peachRingTrigger?.(); }565566**§ 6 --- Autofill: Content Script**567568The content script runs in every page. It must be fast (detection \<56950ms), secure (never expose vault), and resilient (MutationObserver for570SPAs). It communicates ONLY via chrome.runtime.sendMessage.571572> **🔴 CRITICAL: Content scripts NEVER have access to vault entries. The573> service worker returns only the id, title, username, and domain of574> matching entries. Passwords are filled directly by the service worker575> responding to a FILL message --- the content script never sees the576> password string.**577578**src/content/detect.ts --- Form Detection**579580> export interface DetectedField { type: \"username\" \| \"password\" \|581> \"email\"; el: HTMLInputElement; } const USERNAME_PATTERNS =582> /user\|email\|login\|mail\|account\|name/i; const PASSWORD_PATTERNS =583> /pass\|passwd\|pwd/i; export function detectLoginFields(root: Document584> \| ShadowRoot = document): DetectedField\[\] { const inputs =585> Array.from(root.querySelectorAll\<HTMLInputElement\>(\"input\"));586> const fields: DetectedField\[\] = \[\]; for (const el of inputs) { if587> (!isVisible(el)) continue; const ac = el.autocomplete; const type =588> el.type.toLowerCase(); const name = (el.name + el.id +589> el.placeholder).toLowerCase(); // Password: autocomplete \> type \>590> name if (ac === \"current-password\" \|\| ac === \"new-password\" \|\|591> type === \"password\") { fields.push({ type: \"password\", el });592> continue; } if (ac === \"username\" \|\| ac === \"email\" \|\| type593> === \"email\") { fields.push({ type: \"username\", el }); continue; }594> if (USERNAME_PATTERNS.test(name)) { fields.push({ type: \"username\",595> el }); } } return fields; } export function isVisible(el:596> HTMLElement): boolean { const s = getComputedStyle(el); const r =597> el.getBoundingClientRect(); return ( s.display !== \"none\" &&598> s.visibility !== \"hidden\" && parseFloat(s.opacity) \> 0 && r.width599> \> 0 && r.height \> 0 ); } export function getPageDomain(): string {600> const parts = location.hostname.split(\".\"); return601> parts.slice(-2).join(\".\"); }602603**src/content/inject.ts --- Shadow DOM Selector**604605The autofill selector lives in a Shadow DOM. Page CSS cannot touch it.606Page JS cannot read it. The dropdown is removed from the DOM on dismiss607--- not hidden.608609> import type { VaultEntry } from \"../shared/types\"; export interface610> SelectorEntry { id: string; title: string; username: string; } export611> function showAutofillSelector( anchorEl: HTMLInputElement, entries:612> SelectorEntry\[\], onSelect: (id: string) =\> void ): () =\> void { //613> Create isolated container const host =614> document.createElement(\"div\"); host.style.cssText = \` position:615> absolute; z-index: 2147483647; \`; const shadow = host.attachShadow({616> mode: \"closed\" }); // closed = invisible to page JS const rect =617> anchorEl.getBoundingClientRect(); const scrollX = window.scrollX,618> scrollY = window.scrollY; host.style.top = \`\${rect.bottom +619> scrollY + 4}px\`; host.style.left = \`\${rect.left + scrollX}px\`;620> host.style.width = \`\${Math.max(rect.width, 240)}px\`;621> shadow.innerHTML = \` \<style\> :host { all: initial; } .selector {622> background: #111009; border: 1px solid rgba(240,232,216,0.09);623> border-radius: 12px; overflow: hidden; box-shadow: 0 8px 40px624> rgba(0,0,0,0.8); font-family: Inter, sans-serif; } .entry { display:625> flex; flex-direction: column; padding: 10px 14px; cursor: pointer;626> border-bottom: 1px solid rgba(240,232,216,0.05); transition:627> background 120ms; } .entry:last-child { border-bottom: none; }628> .entry:hover { background: rgba(240,232,216,0.04); } .entry-title {629> font-size: 13px; color: #F0E8D8; } .entry-user { font-size: 10px;630> color: rgba(240,232,216,0.4); font-family: \"Courier New\", monospace;631> } \</style\> \<div class=\"selector\"\> \${entries.map(e =\> \` \<div632> class=\"entry\" data-id=\"\${e.id}\"\> \<span633> class=\"entry-title\"\>\${escapeHtml(e.title)}\</span\> \<span634> class=\"entry-user\"\>\${escapeHtml(e.username)}\</span\> \</div\>635> \`).join(\"\")} \</div\> \`;636> shadow.querySelectorAll(\".entry\").forEach(el =\> {637> el.addEventListener(\"click\", () =\> { const id = (el as638> HTMLElement).dataset.id!; onSelect(id); dismiss(); }); });639> document.body.appendChild(host); function dismiss() { host.remove();640> // remove, not hide document.removeEventListener(\"click\",641> outsideClick); } function outsideClick(e: MouseEvent) { if642> (!host.contains(e.target as Node)) dismiss(); } setTimeout(() =\>643> document.addEventListener(\"click\", outsideClick), 0); return644> dismiss; } function escapeHtml(s: string): string { return645> s.replace(/&/g,\"\&amp;\").replace(/\</g,\"\&lt;\").replace(/\>/g,\"\&gt;\").replace(/\"/g,\"\&quot;\");646> }647648**src/content/fill.ts --- Synchronous Field Fill**649650Fill is synchronous. No awaits during the fill path. The entry data is651already in memory from the AUTOFILL_QUERY response. This is how you652achieve \<16ms fill time.653654> import { isVisible } from \"./detect\"; /\*\* Fill username + password655> into detected fields. \* Both fields are set in a single tick --- no656> race conditions. \* Native input event dispatched so React/Vue/Angular657> state updates. \*/ export function fillFields( usernameEl:658> HTMLInputElement \| null, passwordEl: HTMLInputElement \| null,659> username: string, password: string ): void { // Security: verify660> fields are still visible before filling if (usernameEl &&661> isVisible(usernameEl)) { setNativeValue(usernameEl, username); } if662> (passwordEl && isVisible(passwordEl)) { setNativeValue(passwordEl,663> password); } } /\*\* Dispatch synthetic input + change events so664> framework state updates. \*/ function setNativeValue(el:665> HTMLInputElement, value: string): void { const nativeInputValueSetter666> = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype,667> \"value\")?.set; nativeInputValueSetter?.call(el, value);668> el.dispatchEvent(new Event(\"input\", { bubbles: true }));669> el.dispatchEvent(new Event(\"change\", { bubbles: true })); }670>671> **⚠ nativeInputValueSetter is required for React-controlled inputs.672> Setting el.value directly does not trigger React\'s synthetic event673> system. This is the correct approach --- the same technique used by674> Bitwarden and 1Password.**675676**src/content/index.ts --- Content Script Orchestration**677678> import { detectLoginFields, getPageDomain } from \"./detect\"; import679> { showAutofillSelector } from \"./inject\"; import { fillFields } from680> \"./fill\"; import { setupSaveDetection } from \"./save\"; let681> currentFields: ReturnType\<typeof detectLoginFields\> = \[\]; async682> function checkVaultAndInject() { const { data: unlocked } = await683> chrome.runtime.sendMessage({ type: \"IS_UNLOCKED\" }); if (!unlocked)684> return; const domain = getPageDomain(); const { data: matches } =685> await chrome.runtime.sendMessage({ type: \"AUTOFILL_QUERY\", payload:686> { domain } }); if (!matches \|\| matches.length === 0) return;687> currentFields = detectLoginFields(); const pwField =688> currentFields.find(f =\> f.type === \"password\"); const userField =689> currentFields.find(f =\> f.type === \"username\"); if (!pwField)690> return; // Inject Peach icon into the password field691> injectPeachIcon(pwField.el, matches, userField?.el ?? null); }692> function injectPeachIcon( passwordEl: HTMLInputElement, matches:693> any\[\], usernameEl: HTMLInputElement \| null ) { const icon =694> document.createElement(\"button\"); icon.textContent = \"🍑\";695> icon.style.cssText = \` position: absolute; right: 8px; top: 50%;696> transform: translateY(-50%); background: none; border: none; cursor:697> pointer; font-size: 16px; z-index: 2147483646; padding: 4px; \`; const698> wrapper = passwordEl.parentElement; if (!wrapper) return; const699> prevPos = getComputedStyle(wrapper).position; if (prevPos ===700> \"static\") wrapper.style.position = \"relative\";701> wrapper.appendChild(icon); icon.addEventListener(\"click\", (e) =\> {702> e.preventDefault(); e.stopPropagation();703> showAutofillSelector(passwordEl, matches, async (selectedId) =\> { //704> Request the actual password from the background (content never sees705> it) const { data: entry } = await chrome.runtime.sendMessage({ type:706> \"GET_ENTRY_FOR_FILL\", payload: { id: selectedId } }); if (entry)707> fillFields(usernameEl, passwordEl, entry.username, entry.password);708> }); }); } // Run on load if (document.readyState === \"loading\") {709> document.addEventListener(\"DOMContentLoaded\", checkVaultAndInject);710> } else { checkVaultAndInject(); } // Watch for SPAs that load forms711> after initial render new MutationObserver(() =\>712> checkVaultAndInject()).observe( document.body, { childList: true,713> subtree: true } ); setupSaveDetection();714>715> **⚠ You need to add a GET_ENTRY_FOR_FILL message type to messages.ts716> that returns the full entry including password. This message must717> validate that the caller is the extension itself, not a web page.**718719**§ 7 --- Import & Export**720721**CSV Import**722723Generic CSV. Column mapping is explicit --- never guess. Preview before724apply.725726> import Papa from \"papaparse\"; import type { VaultEntry } from727> \"../shared/types\"; export interface CSVMapping { title?: string;728> username?: string; password?: string; url?: string; notes?: string; }729> export function parseCSV(raw: string): { headers: string\[\]; rows:730> Record\<string, string\>\[\] } { const result =731> Papa.parse\<Record\<string, string\>\>(raw, { header: true,732> skipEmptyLines: true, transformHeader: h =\> h.trim() }); return {733> headers: result.meta.fields ?? \[\], rows: result.data }; } export734> function mapCSVToEntries( rows: Record\<string, string\>\[\], mapping:735> CSVMapping ): Array\<Omit\<VaultEntry, \"id\" \| \"created\" \|736> \"updated\"\>\> { return rows .map(row =\> ({ title:737> row\[mapping.title ?? \"\"\] ?? \"\", username: row\[mapping.username738> ?? \"\"\] ?? \"\", password: row\[mapping.password ?? \"\"\] ?? \"\",739> url: row\[mapping.url ?? \"\"\] ?? \"\", notes: row\[mapping.notes ??740> \"\"\] ?? \"\", })) .filter(e =\> e.password); // skip rows with no741> password }742743**1Password JSON Import**744745> // 1Password 1PUX export uses a nested structure. // This handles the746> most common format. export function parse1PasswordJSON(raw: string):747> Array\<Omit\<VaultEntry, \"id\" \| \"created\" \| \"updated\"\>\> {748> const data = JSON.parse(raw); const entries: Array\<Omit\<VaultEntry,749> \"id\" \| \"created\" \| \"updated\"\>\> = \[\]; // 1Password exports:750> { accounts: \[{ vaults: \[{ items: \[\...\] }\] }\] } const vaults =751> data?.accounts?.\[0\]?.vaults ?? \[\]; for (const vault of vaults) {752> for (const item of vault.items ?? \[\]) { if (item.trashed) continue;753> const fields = item.details?.loginFields ?? \[\]; const username =754> fields.find((f: any) =\> f.designation === \"username\")?.value ??755> \"\"; const password = fields.find((f: any) =\> f.designation ===756> \"password\")?.value ?? \"\"; if (!password) continue; entries.push({757> title: item.overview?.title ?? \"Untitled\", username, password, url:758> item.overview?.url ?? \"\", notes: item.details?.notesPlain ?? \"\",759> }); } } return entries; }760761**Encrypted Export**762763> // Export vault as encrypted JSON blob. // Uses a separate password764> (can be the same master password) and a fresh salt. export async765> function exportEncrypted( entries: VaultEntry\[\], exportPassword:766> string ): Promise\<string\> { const { deriveKey, generateSalt } =767> await import(\"../crypto/argon2\"); const { encrypt } = await768> import(\"../crypto/aes\"); // Fresh salt --- independent of vault769> at-rest salt const salt = generateSalt(); const key = await770> deriveKey(exportPassword, salt); const { nonce, ciphertext, tag } =771> await encrypt(key, JSON.stringify(entries)); const blob =772> JSON.stringify({ version: 1, type: \"peach-export\", salt:773> Array.from(salt).map(b =\>774> b.toString(16).padStart(2,\"0\")).join(\"\"), nonce, ciphertext, tag775> }); return blob; } export async function importEncrypted( blob:776> string, exportPassword: string ): Promise\<VaultEntry\[\]\> { const {777> deriveKey } = await import(\"../crypto/argon2\"); const { decrypt } =778> await import(\"../crypto/aes\"); const data = JSON.parse(blob); if779> (data.type !== \"peach-export\" \|\| data.version !== 1) throw new780> Error(\"Not a Peach export file\"); const salt = new781> Uint8Array(data.salt.match(/../g).map((h: string) =\> parseInt(h,782> 16))); const key = await deriveKey(exportPassword, salt); const plain783> = await decrypt(key, data.nonce, data.ciphertext, data.tag); return784> JSON.parse(plain); }785786**§ 8 --- Release Gate: Manual Test Matrix**787788Run every item in this list on Chrome with the unpacked extension before789calling it daily-driverable. Do not skip rows.790791**Vault Core**792793- [ ] Create vault with a real master password794795- [ ] Lock and reload extension, re-unlock with same password796797- [ ] Wrong password → clean failure, no crash, no partial data798799- [ ] Add entry, lock, unlock → entry is still there800801- [ ] Edit entry → changes persist after lock/unlock cycle802803- [ ] Delete entry → gone after lock/unlock804805- [ ] Auto-lock fires after 15 minutes (speed-test: set to 1 minute in806  settings, wait)807808- [ ] Clipboard contains password text, 30 seconds later it is cleared809810**Autofill --- Real Sites**811812Test at minimum these five categories on real sites:813814- [ ] Plain HTML login form (e.g. a small site, not a SPA)815816- [ ] React-controlled login (e.g. GitHub, Notion)817818- [ ] Email-first flow (e.g. Gmail --- first screen is email, second is819  password)820821- [ ] TOTP prompt visible after fill (e.g. GitHub with 2FA enabled)822823- [ ] Site with multiple stored credentials --- correct one is824  pre-highlighted825826For each real-site test, verify:827828- [ ] Peach icon appears in the password field829830- [ ] Selector shows the correct matching entry831832- [ ] Clicking the entry fills both username and password833834- [ ] Fill does NOT happen silently --- user must click835836- [ ] No fill into hidden fields837838- [ ] Wrong-domain entries do NOT appear (e.g. a github.com entry should839  not appear on github.io)840841**Save / Update**842843- [ ] Log into a new site → save banner appears844845- [ ] Banner is subtle and dismissible846847- [ ] Accept save → entry appears in vault848849- [ ] Change password on a site → update banner offered850851- [ ] Update accepted → entry updated in vault852853**Import**854855- [ ] CSV import with column mapping preview works856857- [ ] Entries appear in vault after import858859- [ ] Duplicate handling: importing same entry twice does not create860  corrupt state861862**Export & Restore**863864- [ ] Encrypted export produces a file865866- [ ] Restore from that file with the same password → entries are intact867868**Release Blockers (Never Ship with These)**869870> **🔴 CRITICAL: Wrong credentials filled on the wrong domain**871>872> **🔴 CRITICAL: Fill into hidden fields**873>874> **🔴 CRITICAL: Fill without user interaction (silent autofill)**875>876> **🔴 CRITICAL: Vault fails to lock cleanly --- key lingers in memory**877>878> **🔴 CRITICAL: Import corrupts or overwrites entries incorrectly**879>880> **🔴 CRITICAL: Backup restore fails or returns wrong data**881>882> **🔴 CRITICAL: console.log anywhere in crypto paths**883884**§ 9 --- How to Prompt Your Agent Effectively**885886You are working with AI agents that lose context, overshoot scope, and887invent solutions not in spec. These rules reduce that.888889**Prompt Structure for Each Phase**890891Give each agent exactly one phase at a time. Never bundle phases. The892moment you say \"and also do X and Y\", drift starts.893894Good prompt pattern:895896> You are building the crypto layer of a Chrome extension called Peach.897> Your task for this session: implement src/crypto/argon2.ts and898> src/crypto/aes.ts. Requirements: - argon2.ts must export:899> deriveKey(password: string, salt: Uint8Array): Promise\<Uint8Array\>900> Parameters: m=65536, t=3, p=4, hashLength=32 Library: hash-wasm901> (already installed) - aes.ts must export: encrypt(\...),902> decrypt(\...), zeroKey(\...) Algorithm: AES-256-GCM via SubtleCrypto903> Fresh CSPRNG nonce every encrypt call AAD: Uint8Array(\[1\]) version904> byte Type definitions are in src/shared/types.ts (do not modify it).905> When done, output the file contents only. Do not modify any other906> files. Do not add error tracking, analytics, or logging of any kind.907908**When the Agent Drifts**909910- Stop immediately when the output is modifying files outside the stated911  scope.912913- Paste the failing crypto test output back as context and ask for a fix914  --- do not describe the bug in prose.915916- If the agent adds a permission to manifest.json that is not in § 1,917  reject the whole output and restart that phase.918919- Any console.log in crypto code is an automatic restart.920921**Phase Commit Protocol**922923After each phase:924925- Run npm run typecheck --- zero errors before proceeding.926927- Run npm run build --- build must succeed.928929- Run the crypto tests if in phase 3.930931- Load the unpacked extension in Chrome and run the relevant test matrix932  rows.933934- Commit the phase before starting the next one.935936> **⚠ A \"clean baseline\" means typecheck passes, build succeeds, and937> the extension loads in Chrome without errors. If you do not have this,938> you do not have a baseline --- you have unknown state.**939940**§ 10 --- Tonight\'s Execution Order**941942Follow this sequence exactly. Every phase has a stop condition. If the943stop condition is not met, do not proceed.944945  ---------------- -------------------------------------------------946  **Phase 0**      SCAFFOLD: npm create, install deps, vite.config,947                   manifest.json, file structure. STOP: npm run dev948                   starts without errors.949950  **Phase 1**      CRYPTO: Write argon2.ts, aes.ts, totp.ts. Write951                   and run all four crypto tests. STOP: all tests952                   pass.953954  **Phase 2**      SERVICE WORKER: Write vault.ts, messages.ts,955                   clipboard.ts, alarms, index.ts. STOP: typecheck956                   passes, build succeeds.957958  **Phase 3**      POPUP SHELL: Write popup.html with pre-rendered959                   lock HTML. Write vars.css, popup.css. Mount960                   SolidJS. STOP: popup opens, shows unlock screen,961                   no blank flash.962963  **Phase 4**      VAULT UI: Lock/unlock flow, vault list,964                   add/edit/delete, search, TOTP display, generator,965                   copy with pip pulse and ring. STOP: all vault966                   operations work end-to-end.967968  **Phase 5**      AUTOFILL: detect.ts, inject.ts, fill.ts, content969                   index. STOP: Peach icon appears on a login form,970                   selector shows correct entries, fill works.971972  **Phase 6**      SAVE/UPDATE: save.ts, submit detection,973                   save/update banner. STOP: logging into a new site974                   triggers the save banner.975976  **Phase 7**      IMPORT: CSV + 1Password JSON adapter, preview UI,977                   duplicate handling. STOP: your real passwords are978                   in Peach.979980  **Phase 8**      EXPORT: encrypted export + restore verification.981                   STOP: you can restore your vault from the backup982                   file.983984  **Phase 9**      TEST MATRIX: every row in § 8. Fix only release985                   blockers. STOP: no release blockers remain.986987  **Phase 10**     MIGRATE: add your real passwords, use Peach988                   tonight.989  ---------------- -------------------------------------------------990991> *✦ RULE: Do not start Phase 5 (autofill) until Phase 4 (vault UI) is992> solid. A working vault UI with broken autofill is a daily-driverable993> extension. A working autofill with a broken vault is not.*994995**§ 11 --- Common Failure Modes & Fixes**996997**Service Worker Losing State**998999MV3 service workers terminate when idle. derivedKey will be null when1000the service worker wakes back up even if the vault was unlocked.10011002> // In messages.ts, every handler that touches vault state should: case1003> \"GET_ENTRIES\": if (!vault.isUnlocked()) { return { ok: false, error:1004> \"locked\" }; // popup shows lock screen } return { ok: true, data:1005> vault.getEntries() }; // In the popup App.tsx, on mount: onMount(async1006> () =\> { const { data } = await sendMessage({ type: \"IS_UNLOCKED\"1007> }); if (!data) showLockScreen(); else loadEntries(); }); // This is1008> the correct pattern. The popup always checks state on open. // Never1009> assume the service worker is in the same state it was in last time.10101011**React-Controlled Inputs Not Filling**10121013If fill.ts is setting el.value directly and the site\'s password manager1014doesn\'t respond, you need the nativeInputValueSetter approach in1015fill.ts (already shown in § 6). The key insight: React\'s synthetic1016event system tracks the \"internal\" value, not el.value. You must call1017the native setter to bypass React\'s wrapper.10181019**Content Script / Popup Communication Failing**10201021> // Always use this wrapper instead of raw chrome.runtime.sendMessage1022> async function sendMessage(msg: Message): Promise\<MessageResponse\> {1023> return new Promise((resolve) =\> { chrome.runtime.sendMessage(msg,1024> (response) =\> { if (chrome.runtime.lastError) { resolve({ ok: false,1025> error: chrome.runtime.lastError.message }); } else {1026> resolve(response); } }); }); } // chrome.runtime.lastError MUST be1027> read or Chrome throws a console error. // This wrapper handles it. Use1028> it everywhere.10291030**SolidJS Not Updating After Message**10311032> // SolidJS is fine-grained reactive. You need createSignal. // Never1033> mutate arrays directly --- create new array references. const1034> \[entries, setEntries\] = createSignal\<VaultEntry\[\]\>(\[\]); //1035> WRONG: entries().push(newEntry); // CORRECT: setEntries(prev =\>1036> \[\...prev, newEntry\]);10371038**Argon2id Timing Out in Service Worker**10391040Chrome may kill the service worker during Argon2id derivation (up to10411.2s). Prevent this:10421043> // Keep the service worker alive during KDF by sending periodic alarms1044> // OR use the simpler approach: call chrome.storage.local.get() which1045> keeps SW alive export async function unlock(password: string):1046> Promise\<boolean\> { // Ping storage to keep SW alive during1047> derivation const keepAliveInterval = setInterval(() =\> {1048> chrome.storage.local.get(\"ping\"); }, 100); try { const stored =1049> await loadStoredVault(); if (!stored) return false; const salt =1050> unhex(stored.salt); const key = await deriveKey(password, salt); // up1051> to 1.2s // \... rest of unlock return true; } finally {1052> clearInterval(keepAliveInterval); } }10531054**Final Checklist --- Call It Done When All of This Is True**10551056- [ ] npm run typecheck passes with zero errors10571058- [ ] npm run build succeeds10591060- [ ] Extension loads in Chrome without console errors10611062- [ ] All four crypto tests pass10631064- [ ] Vault create / unlock / lock cycle verified manually10651066- [ ] At least your real essential entries are imported10671068- [ ] Encrypted backup exported and restore verified10691070- [ ] Autofill works on the sites you personally use most10711072- [ ] Save/update flow triggered on at least one real site10731074- [ ] Clipboard clear verified (set timer to 10s for testing)10751076- [ ] Auto-lock verified (set to 1 minute for testing)10771078- [ ] No wrong-domain entries appear in autofill10791080- [ ] No silent fill occurs on any tested site10811082- [ ] No console.log in any crypto file10831084*That is a daily-driver. That is tonight.*