diff --git a/ROADMAP.md b/ROADMAP.md index 221266a..d5320bb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,7 +18,7 @@ Per the 2026-05-30 post-v0.6.0 audit: of the three 2026-05-04 architecture-revie - **Extension restructure** — `vault.ts` split (5 modules), setup.ts SW-abstraction routing, type-checked `shared/state.ts`, SW router-helper dedup, `relicario status` parity, plus P2 cleanups (inactivity timer / gitHost-on-expiry / debounced detector). Effort: **L (multi-day to multi-week)**. Spec: `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` - Plan: not yet written — that's the next concrete move. + Plan: `docs/superpowers/plans/2026-05-30-extension-restructure.md` (6 phases, 24 tasks) ## Medium-term diff --git a/STATUS.md b/STATUS.md index 88aad0b..1e78b49 100644 --- a/STATUS.md +++ b/STATUS.md @@ -128,15 +128,15 @@ Per the 2026-05-30 post-v0.6.0 audit of the three 2026-05-04 architecture-review - **CLI restructure** (`2026-05-04-cli-restructure-design.md`) — *already shipped* as Plan B Cycles 1+2 (`b9bd152`, `3dd1e1b`, `3759f6a`, `e69b347`); the last gap (read-side `refresh_groups_cache` callers in list/get) closed in `d717f0d`. Done-criteria all met. - **Security polish** (`2026-05-04-security-polish-design.md`) — *already shipped* as Stream A Cycle 1 (`89090a8`) plus follow-ups (`0c9387f` start.sh fourth window, `229e483` recovery_qr.rs docs). All four phases done. -- **Extension restructure** (`2026-05-04-extension-restructure-design.md`) — **the only genuinely outstanding spec.** Spot-checks confirm: - - `extension/src/vault/vault.ts` is 1037 LOC (spec criterion: ~200; the split into `vault-shell.ts` / `vault-sidebar.ts` / `vault-list.ts` / `vault-drawer.ts` / `vault-form-wrapper.ts` / `vault-status.ts` has not happened) - - `extension/src/setup/setup.ts` still imports `relicario-wasm` directly (the SW-abstraction-bypass that P1.4 calls out) - - `extension/src/shared/state.ts` still has `any`-typed `StateHost` - - `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` / `itemToManifestEntry` still duplicated across `popup-only.ts` and `content-callable.ts` - - `relicario status` parity gap (no vault-tab equivalent of ahead/behind/lastSyncAt) still open - - Effort estimate from spec: **L (multi-day to multi-week)** - -The next concrete move is **writing an implementation plan** for the extension restructure spec. +- **Extension restructure** (`2026-05-04-extension-restructure-design.md`) — **the only genuinely outstanding spec.** Plan written 2026-05-30: `docs/superpowers/plans/2026-05-30-extension-restructure.md` (6 phases, 24 tasks, ~145 bite-sized steps). + - Phase 1 (S-M): `StateHost` typing + `__resetHostForTests` — foundation, blocks 3 + 4. + - Phase 2 (S): Extract `service-worker/storage.ts` + move `itemToManifestEntry` — independent. + - Phase 3 (L): Setup-wizard SW migration + step registry + `clearWizardState`. + - Phase 4 (M): Split `vault.ts` into 5 modules + lift `vault_locked` channel. + - Phase 5 (M): Five P2 fixes (timer reset rule, gitHost on expiry, teardown dedup, allSettled, MutationObserver debounce) — independent. + - Phase 6 (S-M): `get_vault_status` SW handler + sidebar status indicator (closes the `relicario status` CLI/extension parity gap). + - Recommended sequence: Phase 1 → Phase 2 → Phase 5 → Phase 4 → Phase 6 → Phase 3 (independents first, Phase 3 last because it's the biggest single phase and benefits from all the supporting infrastructure landing first). + - Maximum parallelism (subagent-driven): 3 streams (Phases 1+2+5 concurrent; Phases 4 + 6 sequential after 1; Phase 3 sequential after 1). Beyond extension restructure, ROADMAP medium-term holds Phase 4 command palette (no spec yet). Long-term: relay server, mobile. diff --git a/docs/superpowers/plans/2026-05-30-extension-restructure.md b/docs/superpowers/plans/2026-05-30-extension-restructure.md new file mode 100644 index 0000000..29a76db --- /dev/null +++ b/docs/superpowers/plans/2026-05-30-extension-restructure.md @@ -0,0 +1,2660 @@ +# Extension Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Eliminate the two steepest learning cliffs in the extension — the 1027-LOC `vault.ts` monolith and `setup.ts`'s direct WASM orchestration — and close the last CLI/extension parity gap (`relicario status`) by introducing 6 new modules, 3 new SW message handlers, and typed `StateHost`. + +**Architecture:** Types first, then extract, then split. Phase 1 lays the typed `StateHost` foundation that phases 3 and 4 build on. Phase 2 deduplicates SW router helpers into `storage.ts` and `vault.ts`. Phase 3 moves all setup-wizard crypto orchestration into the SW. Phase 4 splits `vault.ts` into 5 focused modules and lifts the `vault_locked` channel into `shared/state.ts`. Phase 5 sweeps 5 small P2 fixes. Phase 6 adds the `get_vault_status` parity feature. + +**Tech Stack:** TypeScript, vitest + happy-dom, webpack, Rust core via WASM. No new runtime dependencies. + +**Spec:** `docs/superpowers/specs/2026-05-04-extension-restructure-design.md` + +--- + +## File Structure + +### Created + +- `extension/src/shared/popup-state.ts` — `View` + `PopupState` types (moved from `popup/popup.ts`). +- `extension/src/shared/__tests__/state.test.ts` — `StateHost` registration / getState / setState / `__resetHostForTests` coverage. +- `extension/src/shared/__tests__/state-vault-locked.test.ts` — `vault_locked` channel intercept coverage. +- `extension/src/service-worker/storage.ts` — `loadDeviceSettings` / `saveDeviceSettings` / `loadBlacklist` / `saveBlacklist`. +- `extension/src/service-worker/__tests__/storage.test.ts` — storage round-trip coverage. +- `extension/src/service-worker/__tests__/vault.test.ts` (if absent) — `create_vault`, `attach_vault`, `get_vault_status` handler coverage. +- `extension/src/service-worker/__tests__/vault-status.test.ts` — status handler coverage. +- `extension/src/vault/vault-shell.ts` — DOM scaffolding, color-scheme apply, onMessage wiring. +- `extension/src/vault/vault-sidebar.ts` — sidebar categories, debounced search, nav buttons, status slot wiring. +- `extension/src/vault/vault-list.ts` — list pane rendering and row rendering. +- `extension/src/vault/vault-drawer.ts` — drawer open/close/render + `ensureDrawerClosedForRoute`. +- `extension/src/vault/vault-form-wrapper.ts` — `renderFormWrapped` + sticky bar + header. +- `extension/src/vault/vault-status.ts` — sidebar-footer status indicator (Phase 6). +- `extension/src/vault/__tests__/drawer-state.test.ts` — drawer auto-close on navigation. +- `extension/src/vault/__tests__/status-indicator.test.ts` — sidebar status renderer. + +### Modified + +- `extension/src/shared/state.ts` — typed `StateHost`, double-registration guard, `__resetHostForTests`, `sendMessage` wrapper (Phase 1 lays the wrapper; Phase 4 fills the `vault_locked` body). +- `extension/src/shared/messages.ts` — add `create_vault`, `attach_vault`, `get_vault_status` (Phase 3 + 6). +- `extension/src/shared/types.ts` — re-export `View` / `PopupState` from `popup-state.ts` for compatibility (or leave the import paths and skip the re-export — see Task 1.1). +- `extension/src/popup/popup.ts` — drop `View` + `PopupState` definitions (now in `shared/popup-state.ts`); import them instead. +- `extension/src/service-worker/router/popup-only.ts` — delete duplicated `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` / `itemToManifestEntry`; import from `storage.ts` / `vault.ts`. +- `extension/src/service-worker/router/content-callable.ts` — same deduplication. +- `extension/src/service-worker/vault.ts` — gains `itemToManifestEntry` export + 3 new handlers (`create_vault`, `attach_vault`, `get_vault_status`) + cached sync state. +- `extension/src/service-worker/index.ts` — invert inactivity-timer reset rule (Phase 5); clear `state.gitHost` on session expiry. +- `extension/src/service-worker/session-timer.ts` — define `READ_ONLY_CONTENT_CALLABLE` exclusion set with doc comment. +- `extension/src/setup/setup.ts` — delete WASM dynamic-import + `loadWasm` + module `wasm` binding + `verifiedHandle`; convert `renderStepN`/`attachStepN` pairs to `SetupStep` step-registry objects; add `clearWizardState()`. +- `extension/src/setup/__tests__/setup.test.ts` — assert step-registry shape. +- `extension/src/vault/vault.ts` — trim to ~200 LOC of routing + state; delete `vault_locked` RPC intercept (lifted to `shared/state.ts`). +- `extension/src/popup/components/settings.ts` and `settings-vault.ts` — extract `teardownSettingsCommon`; both call it. +- `extension/src/popup/components/devices.ts` and `trash.ts` — switch `Promise.all` to `Promise.allSettled` with per-slot fallback. +- `extension/src/content/detector.ts` — debounce MutationObserver `scan()`. +- `extension/src/__stubs__/relicario_wasm.stub.ts` — round out missing entries needed by the new SW handler tests (per DEV-C P2 note that only ~7 of ~25 are stubbed). + +### Untouched + +- `extension/src/wasm.d.ts` (no new WASM entry points needed; verify in Phase 3 Task 3.2). +- `relicario-core` / `relicario-cli` / `relicario-wasm` Rust crates. +- `extension/src/vault/vault.html` / `vault.css`. +- All Plan A (security/docs polish) territory. +- All Plan B (CLI restructure) territory. + +--- + +## Phase 1 — `StateHost` typing + `__resetHostForTests` (P1.6) + +**Effort:** S-M. **Depends on:** none. **Blocks:** Phases 3, 4. + +### Task 1.1: Move `View` and `PopupState` to `shared/popup-state.ts` + +**Files:** +- Create: `extension/src/shared/popup-state.ts` +- Modify: `extension/src/popup/popup.ts` (drop definitions, import from new location) +- Modify: any callers that import `View` / `PopupState` from `popup/popup.ts` + +- [ ] **Step 1: Identify all importers** + +```bash +cd extension && grep -rn "from '\.\./popup/popup'\|from '\.\./\.\./popup/popup'\|from '\./popup/popup'" src/ | grep -i "View\|PopupState" +``` + +Record the list. Most are in `src/popup/components/*.ts` and `src/vault/vault.ts`. + +- [ ] **Step 2: Read `popup.ts` to find the current `View` and `PopupState` definitions** + +```bash +cd extension && grep -n "export type View\|export interface PopupState\|export type PopupState" src/popup/popup.ts +``` + +Copy the type and interface bodies verbatim. + +- [ ] **Step 3: Create `shared/popup-state.ts` with the copied types** + +`extension/src/shared/popup-state.ts`: + +```typescript +// State shared between popup and vault surfaces. Kept here (not in popup/) so +// shared/state.ts can import without creating a popup→shared circular dep. + +import type { ItemId, ManifestEntry, ItemType } from './types'; + +export type View = + | 'unlock' + | 'list' + | 'detail' + | 'add' + | 'edit' + | 'history' + | 'settings' + | 'devices' + | 'trash' + | 'backup' + | 'import'; + +export interface PopupState { + view: View; + loading: boolean; + error: string | null; + entries: Array<[ItemId, ManifestEntry]>; + selectedItem: import('./types').Item | null; + searchQuery: string; + newType: ItemType | null; + // … copy every field from popup.ts's current PopupState exactly +} +``` + +**Important:** copy the existing `PopupState` body field-for-field; do not invent fields. If the popup currently has narrower or wider types, preserve them. Use a brief comment if a field's purpose is non-obvious (mirror existing comments). + +- [ ] **Step 4: Update `popup/popup.ts` to import the moved types** + +Replace the original `export type View = …` and `export interface PopupState { … }` blocks in `extension/src/popup/popup.ts` with: + +```typescript +export type { View, PopupState } from '../shared/popup-state'; +``` + +(Re-export keeps existing consumers happy without a wider sweep in this task; Phase 1 Task 1.4 sweeps them.) + +- [ ] **Step 5: Build to verify** + +```bash +cd extension && npx tsc --noEmit 2>&1 | tail -20 +``` + +Expected: clean compile (the re-export means existing import paths still resolve). + +- [ ] **Step 6: Run vitest** + +```bash +cd extension && npx vitest run +``` + +Expected: all current tests pass (the type relocation is no-behavior-change). + +- [ ] **Step 7: Commit** + +```bash +cd extension/.. && git add extension/src/shared/popup-state.ts extension/src/popup/popup.ts +git commit -m "refactor(ext/shared): move View + PopupState to shared/popup-state.ts + +Foundation for Plan C Phase 1: shared/state.ts (next task) needs to import +PopupState without creating a popup→shared circular dep. popup.ts now +re-exports from the new location so existing callers don't break in this +task. Task 1.4 will sweep them onto the canonical import path. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 1.2: Rewrite `shared/state.ts` with typed `StateHost` + double-register guard + `__resetHostForTests` + +**Files:** +- Modify: `extension/src/shared/state.ts` + +- [ ] **Step 1: Read current state.ts** + +```bash +cd extension && cat src/shared/state.ts +``` + +Record the current shape (functions, the `host` singleton, any inline types). + +- [ ] **Step 2: Rewrite `state.ts` with the typed contract** + +Replace the entire file with: + +```typescript +// extension/src/shared/state.ts +// +// Single channel for popup and vault-tab UI to read/write app state and +// dispatch messages to the service worker. Two registered hosts (popup, +// vault tab) implement StateHost; each surface calls registerHost(this) at +// boot. +// +// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts +// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature; +// the body is a thin pass-through until Phase 4. + +import type { Request, Response } from './messages'; +import type { PopupState, View } from './popup-state'; + +export interface StateHost { + getState(): PopupState; + setState(partial: Partial): void; + navigate(view: View, extras?: Partial): void; + sendMessage(request: Request): Promise; + escapeHtml(s: string): string; + popOutToTab(): void; + isInTab(): boolean; + openVaultTab(hash?: string): void; +} + +let host: StateHost | null = null; + +export function registerHost(h: StateHost): void { + if (host) throw new Error('state host already registered'); + host = h; +} + +/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */ +export function __resetHostForTests(): void { + host = null; +} + +export function getState(): PopupState { + if (!host) throw new Error('No state host registered'); + return host.getState(); +} + +export function setState(partial: Partial): void { + if (!host) throw new Error('No state host registered'); + host.setState(partial); +} + +export function navigate(view: View, extras?: Partial): void { + if (!host) throw new Error('No state host registered'); + host.navigate(view, extras); +} + +/** + * Phase 4 will add a vault_locked intercept here. For now, this is a pure + * pass-through so the signature is stable for Phase 4 to fill. + */ +export async function sendMessage(request: Request): Promise { + if (!host) throw new Error('No state host registered'); + return host.sendMessage(request); +} + +export function escapeHtml(s: string): string { + if (!host) throw new Error('No state host registered'); + return host.escapeHtml(s); +} + +export function popOutToTab(): void { + if (!host) throw new Error('No state host registered'); + host.popOutToTab(); +} + +export function isInTab(): boolean { + if (!host) throw new Error('No state host registered'); + return host.isInTab(); +} + +export function openVaultTab(hash?: string): void { + if (!host) throw new Error('No state host registered'); + host.openVaultTab(hash); +} +``` + +- [ ] **Step 3: Build to verify** + +```bash +cd extension && npx tsc --noEmit 2>&1 | tail -30 +``` + +Expected: TS errors will surface in callers that previously relied on `any`-typed access. Record the error list (it will be the work surface for Task 1.4). + +- [ ] **Step 4: Commit (skip if step 3 shows errors)** + +If step 3 is clean, commit. If there are errors, hold the commit until Task 1.4 fixes the callers — bundle them into one commit there. + +```bash +cd extension/.. && git add extension/src/shared/state.ts +# Hold commit until callers compile clean (Task 1.4) +``` + +--- + +### Task 1.3: Sweep `as any` casts on `getState`/`setState`/`navigate` call sites + +**Files:** +- Modify: every caller of `getState`/`setState`/`navigate`/`sendMessage`/`escapeHtml`/`popOutToTab`/`isInTab`/`openVaultTab` from `shared/state` that has a TS error after Task 1.2. + +- [ ] **Step 1: Get the TS error list** + +```bash +cd extension && npx tsc --noEmit 2>&1 | grep "error TS" | head -50 +``` + +Expected: 15-30 errors in `popup/components/*.ts` and `vault/vault.ts`. + +- [ ] **Step 2: Fix each error** + +For each error, the fix pattern is: +- `(state as any).field` → `state.field` (the field exists in `PopupState`; remove the cast). +- `setState({ field: value } as any)` → `setState({ field: value })` (type the field correctly). +- `setState({ foo: x } as unknown as PopupState)` → `setState({ foo: x })` (if `foo` is in `PopupState`). +- If a caller is using a field that genuinely isn't in `PopupState`, add it to `PopupState` in `shared/popup-state.ts` with a comment justifying its addition. + +Do not introduce new `as any` casts. The goal is removing them, not relocating. + +- [ ] **Step 3: Re-run tsc** + +```bash +cd extension && npx tsc --noEmit 2>&1 | tail -10 +``` + +Expected: clean. + +- [ ] **Step 4: Run vitest** + +```bash +cd extension && npx vitest run +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit (bundle with Task 1.2 if held)** + +```bash +cd extension/.. && git add extension/src/shared/state.ts $(git diff --name-only -- extension/src/) +git commit -m "refactor(ext/shared): typed StateHost + sweep as-any casts (Plan C Phase 1) + +Replaces the previously any-typed StateHost contract with a typed interface. +Adds double-registration guard and __resetHostForTests for vitest. +sendMessage wrapper is currently a pass-through; Phase 4 will fill its body +with the vault_locked intercept lifted from vault.ts. + +Sweeps callers that relied on as-any to access typed PopupState fields. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 1.4: Sweep imports of `View` and `PopupState` to canonical path + +**Files:** +- Modify: every caller still importing `View` / `PopupState` from `popup/popup.ts`. + +- [ ] **Step 1: Find remaining importers** + +```bash +cd extension && grep -rn "from '\.\./popup/popup'\|from '\.\./\.\./popup/popup'" src/ | grep -E "View|PopupState" +``` + +- [ ] **Step 2: Rewrite each import** + +For each match, change: + +```typescript +import type { View, PopupState } from '../popup/popup'; +``` + +To: + +```typescript +import type { View, PopupState } from '../shared/popup-state'; +``` + +(Adjust the relative path per the file location.) + +- [ ] **Step 3: Remove the re-export from `popup/popup.ts`** + +Delete the `export type { View, PopupState } from '../shared/popup-state';` line added in Task 1.1. + +- [ ] **Step 4: Build + test** + +```bash +cd extension && npx tsc --noEmit && npx vitest run +``` + +Both should be clean. + +- [ ] **Step 5: Commit** + +```bash +cd extension/.. && git add $(git diff --name-only -- extension/src/) extension/src/popup/popup.ts +git commit -m "refactor(ext): sweep View/PopupState imports to shared/popup-state (Plan C Phase 1) + +Removes the re-export shim from popup/popup.ts now that all callers point +at the canonical shared/popup-state. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 1.5: Add `state.test.ts` coverage + +**Files:** +- Create: `extension/src/shared/__tests__/state.test.ts` + +- [ ] **Step 1: Write the test file** + +`extension/src/shared/__tests__/state.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + registerHost, + __resetHostForTests, + getState, + setState, + navigate, + sendMessage, +} from '../state'; +import type { StateHost } from '../state'; +import type { PopupState } from '../popup-state'; + +function makeHost(initial?: Partial): StateHost { + let state: PopupState = { + view: 'list', + loading: false, + error: null, + entries: [], + selectedItem: null, + searchQuery: '', + newType: null, + ...initial, + } as PopupState; + + return { + getState: () => state, + setState: (partial) => { state = { ...state, ...partial }; }, + navigate: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: true }), + escapeHtml: (s) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), + }; +} + +describe('shared/state', () => { + beforeEach(() => { + __resetHostForTests(); + }); + + it('register-then-getState round-trips', () => { + const host = makeHost({ view: 'detail' }); + registerHost(host); + expect(getState().view).toBe('detail'); + }); + + it('double-register throws', () => { + registerHost(makeHost()); + expect(() => registerHost(makeHost())).toThrow(/already registered/); + }); + + it('__resetHostForTests clears the singleton', () => { + registerHost(makeHost()); + __resetHostForTests(); + expect(() => getState()).toThrow(/No state host/); + }); + + it('getState without host throws', () => { + expect(() => getState()).toThrow(/No state host/); + }); + + it('setState merges partial state', () => { + const host = makeHost(); + registerHost(host); + setState({ loading: true }); + expect(getState().loading).toBe(true); + }); + + it('navigate delegates to host', () => { + const host = makeHost(); + registerHost(host); + navigate('settings'); + expect(host.navigate).toHaveBeenCalledWith('settings', undefined); + }); + + it('sendMessage delegates to host', async () => { + const host = makeHost(); + registerHost(host); + const resp = await sendMessage({ type: 'is_unlocked' }); + expect(host.sendMessage).toHaveBeenCalledWith({ type: 'is_unlocked' }); + expect(resp).toEqual({ ok: true }); + }); +}); +``` + +- [ ] **Step 2: Run the test** + +```bash +cd extension && npx vitest run src/shared/__tests__/state.test.ts +``` + +Expected: 7 passed. + +- [ ] **Step 3: Commit** + +```bash +cd extension/.. && git add extension/src/shared/__tests__/state.test.ts +git commit -m "test(ext/shared): cover StateHost registration + reset (Plan C Phase 1) + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Phase 2 — Extract `service-worker/storage.ts` + move `itemToManifestEntry` (P1.9) + +**Effort:** S. **Depends on:** none (parallel with Phase 1). **Blocks:** none. + +### Task 2.1: Create `service-worker/storage.ts` + +**Files:** +- Create: `extension/src/service-worker/storage.ts` +- Read: `extension/src/service-worker/router/popup-only.ts:687-703` +- Read: `extension/src/service-worker/router/content-callable.ts:187-205` + +- [ ] **Step 1: Read the existing helper bodies** + +```bash +cd extension && grep -A 20 "^function loadDeviceSettings\|^function saveDeviceSettings\|^function loadBlacklist\|^function saveBlacklist" src/service-worker/router/popup-only.ts src/service-worker/router/content-callable.ts +``` + +Record the four function bodies. They should be identical or near-identical between the two router files (that's the point of P1.9). + +- [ ] **Step 2: Create `storage.ts`** + +`extension/src/service-worker/storage.ts`: + +```typescript +// Single home for chrome.storage.local reads/writes done by the service +// worker. Both router files (popup-only.ts and content-callable.ts) import +// from here — the duplicated definitions in those files lift out as part +// of Plan C Phase 2. + +import type { DeviceSettings } from '../shared/types'; + +const DEFAULT_DEVICE_SETTINGS: DeviceSettings = { + captureEnabled: false, + captureStyle: 'bar', +}; + +export async function loadDeviceSettings(): Promise { + // Copy the body from popup-only.ts's loadDeviceSettings verbatim, then + // replace any inline default with DEFAULT_DEVICE_SETTINGS above. + const stored = await chrome.storage.local.get(['device_settings']); + return (stored.device_settings as DeviceSettings | undefined) + ?? DEFAULT_DEVICE_SETTINGS; +} + +export async function saveDeviceSettings(settings: DeviceSettings): Promise { + await chrome.storage.local.set({ device_settings: settings }); +} + +export async function loadBlacklist(): Promise { + // Copy the body from popup-only.ts's loadBlacklist verbatim. + const stored = await chrome.storage.local.get(['capture_blacklist']); + return (stored.capture_blacklist as string[] | undefined) ?? []; +} + +export async function saveBlacklist(hosts: string[]): Promise { + await chrome.storage.local.set({ capture_blacklist: hosts }); +} +``` + +**Important:** Step 1 produced the actual bodies. Use those verbatim. The snippets above are placeholders illustrating the shape; if the real bodies have different defaults / serialization, preserve them exactly. + +- [ ] **Step 3: Run vitest (verify the file imports cleanly)** + +```bash +cd extension && npx vitest run +``` + +Expected: all existing tests pass (no behavior change yet — storage.ts is unused so far). + +- [ ] **Step 4: Hold commit until Task 2.3 deduplicates the routers** + +--- + +### Task 2.2: Move `itemToManifestEntry` to `service-worker/vault.ts` + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` +- Read: `extension/src/service-worker/router/popup-only.ts:707` (locate `itemToManifestEntry`) +- Read: `extension/src/service-worker/router/content-callable.ts:169` (locate the duplicate) + +- [ ] **Step 1: Read both definitions and confirm they match** + +```bash +cd extension && grep -A 25 "function itemToManifestEntry" src/service-worker/router/popup-only.ts src/service-worker/router/content-callable.ts +``` + +If they're identical, proceed. If they differ, log the difference and pick the more recent / more correct version (typically `popup-only.ts`'s, since that file gets more attention). + +- [ ] **Step 2: Add the function to `service-worker/vault.ts` as a named export** + +Append to `extension/src/service-worker/vault.ts`: + +```typescript +import type { Item, ManifestEntry } from '../shared/types'; + +/** + * Project a decrypted Item into its ManifestEntry shape for browse-without- + * decrypt views. Both router files use this; defined here (the SW's + * vault-orchestration home) instead of duplicated in each router. + */ +export function itemToManifestEntry(item: Item): ManifestEntry { + // Paste the body from popup-only.ts:707 verbatim. + return { + id: item.id, + title: item.title, + type: item.r#type, + // … etc. — paste exact body + }; +} +``` + +- [ ] **Step 3: Hold commit until Task 2.3 cleans up the duplicates** + +--- + +### Task 2.3: Dedupe router files + add `storage.test.ts` + +**Files:** +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Modify: `extension/src/service-worker/router/content-callable.ts` +- Create: `extension/src/service-worker/__tests__/storage.test.ts` + +- [ ] **Step 1: Replace inline functions with imports in popup-only.ts** + +In `extension/src/service-worker/router/popup-only.ts`: +- Delete the `function loadDeviceSettings`, `function saveDeviceSettings`, `function loadBlacklist`, `function saveBlacklist`, `function itemToManifestEntry` definitions. +- Add at the top of the file: + +```typescript +import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } + from '../storage'; +import { itemToManifestEntry } from '../vault'; +``` + +- [ ] **Step 2: Same for content-callable.ts** + +In `extension/src/service-worker/router/content-callable.ts`: +- Delete the same five definitions. +- Add the same imports. + +- [ ] **Step 3: Write `storage.test.ts`** + +`extension/src/service-worker/__tests__/storage.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadDeviceSettings, saveDeviceSettings, loadBlacklist, saveBlacklist } + from '../storage'; + +function mockChromeStorage(initial: Record = {}) { + const store: Record = { ...initial }; + (global as { chrome: unknown }).chrome = { + storage: { + local: { + get: vi.fn((keys: string | string[]) => { + const arr = Array.isArray(keys) ? keys : [keys]; + const out: Record = {}; + for (const k of arr) if (k in store) out[k] = store[k]; + return Promise.resolve(out); + }), + set: vi.fn((kv: Record) => { + Object.assign(store, kv); + return Promise.resolve(); + }), + }, + }, + } as never; + return store; +} + +describe('service-worker/storage', () => { + beforeEach(() => { mockChromeStorage(); }); + + it('loadDeviceSettings returns default when storage is empty', async () => { + const s = await loadDeviceSettings(); + expect(s.captureEnabled).toBe(false); + expect(s.captureStyle).toBe('bar'); + }); + + it('loadDeviceSettings returns stored value', async () => { + mockChromeStorage({ device_settings: { captureEnabled: true, captureStyle: 'toast' } }); + const s = await loadDeviceSettings(); + expect(s.captureEnabled).toBe(true); + expect(s.captureStyle).toBe('toast'); + }); + + it('saveDeviceSettings persists', async () => { + const store = mockChromeStorage(); + await saveDeviceSettings({ captureEnabled: true, captureStyle: 'bar' }); + expect(store.device_settings).toEqual({ captureEnabled: true, captureStyle: 'bar' }); + }); + + it('loadBlacklist returns empty array by default', async () => { + expect(await loadBlacklist()).toEqual([]); + }); + + it('saveBlacklist / loadBlacklist round-trips', async () => { + await saveBlacklist(['example.com', 'evil.test']); + expect(await loadBlacklist()).toEqual(['example.com', 'evil.test']); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +cd extension && npx vitest run src/service-worker/__tests__/storage.test.ts +``` + +Expected: 5 passed. + +- [ ] **Step 5: Run full suite** + +```bash +cd extension && npx vitest run +``` + +Expected: all tests pass (the router tests still pass because they exercise dispatch behavior, which is unchanged). + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/service-worker/storage.ts extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/router/content-callable.ts extension/src/service-worker/__tests__/storage.test.ts +git commit -m "refactor(ext/sw): extract storage.ts + move itemToManifestEntry (Plan C Phase 2) + +P1.9: loadDeviceSettings / loadBlacklist / saveBlacklist / saveDeviceSettings ++ itemToManifestEntry were duplicated across popup-only.ts and +content-callable.ts. Lifts the four storage helpers into service-worker/ +storage.ts and itemToManifestEntry into service-worker/vault.ts. + +Both router files now import from one home each. Adds storage.test.ts +covering round-trips and defaults. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Phase 3 — Setup wizard SW migration + step registry (P1.4) + +**Effort:** L. **Depends on:** Phase 1. + +### Task 3.1: Add `create_vault` / `attach_vault` / `get_vault_status` to messages.ts + +**Files:** +- Modify: `extension/src/shared/messages.ts` + +- [ ] **Step 1: Read current Request union and POPUP_ONLY_TYPES set** + +```bash +cd extension && grep -n "POPUP_ONLY_TYPES\|export type Request" src/shared/messages.ts | head -10 +``` + +- [ ] **Step 2: Add the three new request shapes** + +In `extension/src/shared/messages.ts`, add to the `Request` union: + +```typescript +| { type: 'create_vault'; config: VaultConfig; passphrase: string; + carrierImageBytes: ArrayBuffer; deviceName: string } +| { type: 'attach_vault'; config: VaultConfig; passphrase: string; + referenceImageBytes: ArrayBuffer; deviceName: string } +| { type: 'get_vault_status' } +``` + +(`VaultConfig` is presumably already defined in `shared/types.ts` or similar — find and import if needed.) + +- [ ] **Step 3: Add the response interfaces** + +Below the existing response interfaces, add: + +```typescript +export interface CreateVaultResponse { + ok: true; + data: { + referenceImageBytes: Uint8Array; + deviceName: string; + recoveryQrAvailable: true; + }; +} + +export interface AttachVaultResponse { + ok: true; + data: { deviceName: string }; +} + +export interface GetVaultStatusResponse { + ok: true; + data: { + ahead: number; + behind: number; + lastSyncAt: number | null; + pendingItems: number; + }; +} +``` + +- [ ] **Step 4: Add the three types to `POPUP_ONLY_TYPES`** + +Find the set and add: + +```typescript +'create_vault', +'attach_vault', +'get_vault_status', +``` + +- [ ] **Step 5: Build to verify** + +```bash +cd extension && npx tsc --noEmit 2>&1 | tail -10 +``` + +Expected: clean (the new types aren't consumed yet). + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/shared/messages.ts +git commit -m "feat(ext/messages): add create_vault, attach_vault, get_vault_status (Plan C Phase 3 prep) + +Adds the request shapes + response interfaces. POPUP_ONLY_TYPES set grows +by three. SW handlers in service-worker/vault.ts land in the next tasks. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 3.2: Implement `create_vault` SW handler + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` (dispatch entry) +- Modify: `extension/src/__stubs__/relicario_wasm.stub.ts` (round out missing entries needed by the test) +- Create: `extension/src/service-worker/__tests__/vault.test.ts` (if absent — check first) + +- [ ] **Step 1: Read the existing setup.ts crypto orchestration** + +```bash +cd extension && grep -n "embed_image_secret\|register_device\|manifest_encrypt" src/setup/setup.ts | head -10 +``` + +Record the call sequence: the spec says it's `unlock` → `embed_image_secret` → `register_device` → `manifest_encrypt` then push to git host. + +- [ ] **Step 2: Check whether vault.test.ts already exists** + +```bash +cd extension && ls src/service-worker/__tests__/ +``` + +If `vault.test.ts` is absent, create it in Step 5 of this task; otherwise add a `describe('create_vault', ...)` block. + +- [ ] **Step 3: Write the failing test first** + +`extension/src/service-worker/__tests__/vault.test.ts` (or append a describe block): + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// Will need: +// - mock chrome.storage.local +// - stub the GitHost via a fake implementation +// - extend relicario_wasm.stub.ts to return realistic values for unlock, +// embed_image_secret, register_device, manifest_encrypt +// - call route({ type: 'create_vault', ... }, makeState(), popupSender) +// - assert the response shape: { ok: true, data: { referenceImageBytes, deviceName, recoveryQrAvailable } } + +describe('create_vault SW handler', () => { + beforeEach(() => { + // chrome.* mock, gitHost mock, wasm stub + }); + + it('runs the create flow end-to-end and returns reference image bytes', async () => { + // … set up state with a stubbed gitHost that captures putBlob calls + // … set up wasm stub: unlock → handle, embed_image_secret → bytes, + // register_device → keypair, manifest_encrypt → bytes + // … call the create_vault handler + // … assert response.data.referenceImageBytes is a Uint8Array + // … assert gitHost.putBlob was called with manifest.enc + params.json + }); + + it('returns ok:false with a useful error when image embedding fails', async () => { + // … wasm stub embed_image_secret throws + // … assert response.ok === false and error string is descriptive + }); +}); +``` + +- [ ] **Step 4: Run the test (expect FAIL — handler not implemented)** + +```bash +cd extension && npx vitest run src/service-worker/__tests__/vault.test.ts +``` + +Expected: FAIL (no handler dispatched). + +- [ ] **Step 5: Implement the handler in `service-worker/vault.ts`** + +Add to `extension/src/service-worker/vault.ts`: + +```typescript +import type { CreateVaultResponse } from '../shared/messages'; + +/** + * Creates a new vault end-to-end inside the SW. Holds its own SessionHandle + * for the duration; does not depend on the user-facing inactivity timer. + * On success, transitions the SW into the unlocked state (the SessionHandle + * stays alive until the SW receives a lock message or the timer expires). + * On failure, the handle is locked (zeroize key) then freed. + * + * Cite: docs/superpowers/specs/2026-05-04-extension-restructure-design.md + * phase 3 (P1.4) for the rationale on moving this out of setup.ts. + */ +export async function handleCreateVault( + msg: { config: VaultConfig; passphrase: string; + carrierImageBytes: ArrayBuffer; deviceName: string }, + state: RouterState, +): Promise { + let handle: SessionHandle | null = null; + try { + // 1. Embed the random secret into the carrier image. + const carrierBytes = new Uint8Array(msg.carrierImageBytes); + const referenceImageBytes = state.wasm.embed_image_secret(carrierBytes); + + // 2. Derive Argon2id params; compute master key via unlock. + const params = state.wasm.default_vault_settings_json(); + // … (orchestration body — paste from setup.ts:NNN-NNN with adaptation) + + // 3. Register the device, get the device keypair. + const keys = state.wasm.register_device(msg.deviceName) as { + signing_public_key: string; + deploy_public_key: string; + }; + + // 4. Encrypt the empty manifest + push to remote. + const manifestBytes = state.wasm.manifest_encrypt(emptyManifestJson, handle); + await state.gitHost!.putBlob('manifest.enc', manifestBytes); + // … push params.json, devices.json, etc. via gitHost + + // 5. Transition SW into unlocked state. + state.sessionHandle = handle; + handle = null; // ownership transferred — don't lock-and-free in finally + + return { + ok: true, + data: { + referenceImageBytes, + deviceName: msg.deviceName, + recoveryQrAvailable: true, + }, + }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } finally { + // Per Plan A: lock then free if we still own the handle. + if (handle) { + try { state.wasm.lock(handle); } catch { /* lock already happened */ } + handle.free(); + } + } +} +``` + +**Important:** the orchestration body (steps 1-4) requires copying the exact sequence from `setup.ts`. Read `setup.ts` carefully to mirror the existing logic — do not invent new steps. The plan can't show every line here because the setup.ts body is the source of truth. + +- [ ] **Step 6: Wire the dispatch in `service-worker/router/popup-only.ts`** + +Add a case to the router's switch: + +```typescript +case 'create_vault': + return handleCreateVault(msg, state); +``` + +Import `handleCreateVault` from `../vault`. + +- [ ] **Step 7: Round out the WASM stub for the test** + +In `extension/src/__stubs__/relicario_wasm.stub.ts`, ensure the four functions used by the handler return usable values when `state.wasm.X = ...` is overridden in the test. Add stub entries that throw a clear "not mocked" message: + +```typescript +export const default_vault_settings_json = (): string => '{}'; +export const embed_image_secret = (): never => { + throw new Error('wasm stub: embed_image_secret not mocked'); +}; +// (register_device and manifest_encrypt already in stub per Task 1) +``` + +- [ ] **Step 8: Run the test (expect PASS)** + +```bash +cd extension && npx vitest run src/service-worker/__tests__/vault.test.ts +``` + +Expected: both cases pass. + +- [ ] **Step 9: Commit** + +```bash +cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/__stubs__/relicario_wasm.stub.ts extension/src/service-worker/__tests__/vault.test.ts +git commit -m "feat(ext/sw): create_vault handler (Plan C Phase 3) + +Lifts the create-vault orchestration out of setup.ts into the SW. The +handler holds its own SessionHandle for the duration; on success the +handle transitions to SW-owned (unlocked state). On failure, the handle +is locked then freed per Plan A's policy. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 3.3: Implement `attach_vault` SW handler + +**Files:** +- Modify: `extension/src/service-worker/vault.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Modify: `extension/src/service-worker/__tests__/vault.test.ts` + +- [ ] **Step 1: Add the attach test block** + +In `extension/src/service-worker/__tests__/vault.test.ts`: + +```typescript +describe('attach_vault SW handler', () => { + it('extracts image secret, derives key, registers device', async () => { + // … wasm stub: extract_image_secret → bytes, unlock → handle, + // register_device → keypair + // … call attach_vault handler with a reference JPEG + // … assert response.data.deviceName matches input + }); +}); +``` + +- [ ] **Step 2: Run (expect FAIL)** + +- [ ] **Step 3: Implement `handleAttachVault` in `service-worker/vault.ts`** + +Same shape as `handleCreateVault` but the crypto sequence is: +1. `extract_image_secret(referenceImageBytes)` → 32-byte secret +2. `unlock(passphrase, image_secret, params)` → SessionHandle +3. `register_device(deviceName)` → keypair +4. Persist device.json to gitHost. + +Refer to `setup.ts` for the exact sequence currently used in the attach flow. + +- [ ] **Step 4: Wire dispatch in popup-only.ts** + +```typescript +case 'attach_vault': + return handleAttachVault(msg, state); +``` + +- [ ] **Step 5: Run test (expect PASS)** + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/__tests__/vault.test.ts +git commit -m "feat(ext/sw): attach_vault handler (Plan C Phase 3) + +Same shape as create_vault: SW owns image-secret extract + unlock + +register_device + device.json persist. setup.ts will call this in place +of its current direct WASM orchestration in Task 3.5. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 3.4: Delete WASM imports + `loadWasm` + `verifiedHandle` from setup.ts + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +- [ ] **Step 1: Locate the WASM imports** + +```bash +cd extension && grep -n "from 'relicario-wasm'\|loadWasm\|verifiedHandle" src/setup/setup.ts | head -10 +``` + +- [ ] **Step 2: Delete the dynamic import block at lines 28-37** + +Remove the `loadWasm()` helper and the module-level `wasm` variable. Setup no longer talks to WASM directly. + +- [ ] **Step 3: Delete `verifiedHandle` from `WizardState`** + +Find the `WizardState` interface in setup.ts and remove the `verifiedHandle?: SessionHandle | null` field. + +- [ ] **Step 4: Remove the `SessionHandle` import** + +The `import type { SessionHandle } from 'relicario-wasm';` line goes too. + +- [ ] **Step 5: Build (expect errors)** + +```bash +cd extension && npx tsc --noEmit 2>&1 | grep "setup" | head -10 +``` + +Expected: errors where `wasm.X(...)` is called inline. These get fixed in Task 3.5. + +- [ ] **Step 6: Hold commit until Task 3.5 makes the file compile** + +--- + +### Task 3.5: Replace WASM calls with `sendMessage(create_vault / attach_vault)` + step registry + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +- [ ] **Step 1: Find the `wasm.X(...)` callsites** + +```bash +cd extension && grep -n "wasm\." src/setup/setup.ts | head -20 +``` + +There should be ~10-15 sites across the create flow, the attach flow, and the device-register step. + +- [ ] **Step 2: Replace the create-vault sequence with one `sendMessage`** + +Find the block (likely in `attachStep3New` or similar) that calls `wasm.embed_image_secret`, `wasm.unlock`, `wasm.register_device`, `wasm.manifest_encrypt`. Replace with: + +```typescript +const resp = await sendMessage({ + type: 'create_vault', + config: state.vaultConfig!, + passphrase: state.passphrase!, + carrierImageBytes: state.carrierImageBytes!.buffer, + deviceName: state.deviceName!, +}); +if (!resp.ok) { + state.error = resp.error; + return rerender(); +} +state.referenceImageBytes = new Uint8Array(resp.data.referenceImageBytes); +``` + +- [ ] **Step 3: Replace the attach-vault sequence** + +Same pattern with `{ type: 'attach_vault', ... }`. + +- [ ] **Step 4: Convert `renderStep*` / `attachStep*` pairs to `SetupStep` objects** + +Define the contract at the top of setup.ts: + +```typescript +interface StepContext { + state: WizardState; + rerender: () => void; + goto: (id: StepId) => void; +} + +interface SetupStep { + id: StepId; + render: (ctx: StepContext) => string; + attach: (root: HTMLElement, ctx: StepContext) => () => void; +} + +type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; +``` + +Then for each existing `renderStepN` + `attachStepN` pair: + +```typescript +const modeStep: SetupStep = { + id: 'mode', + render: (ctx) => { /* paste renderStep0 body */ }, + attach: (root, ctx) => { /* paste attachStep0 body; return teardown */ }, +}; + +// … hostStep, connectionStep, vaultStep, deviceStep, doneStep +``` + +Then the `STEPS` array + the wizard's main render loop: + +```typescript +const STEPS: ReadonlyArray = [ + modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, +]; + +function rerender() { + const ctx = { state, rerender, goto }; + const step = STEPS.find((s) => s.id === state.currentStep)!; + app.innerHTML = step.render(ctx); + const teardown = step.attach(app, ctx); + // … record teardown for cleanup on next rerender +} +``` + +The wizard's existing init code calls `rerender()` once on boot. + +- [ ] **Step 5: Build to verify** + +```bash +cd extension && npx tsc --noEmit 2>&1 | grep "setup" | head -10 +``` + +Expected: clean (or close to it — fix any remaining errors). + +- [ ] **Step 6: Run existing setup tests (expect FAIL — they assert on old shape)** + +```bash +cd extension && npx vitest run src/setup/__tests__/setup.test.ts +``` + +Expected: failures. These get fixed in Task 3.7. + +- [ ] **Step 7: Hold commit until Task 3.7 updates tests** + +--- + +### Task 3.6: Add `clearWizardState` + `beforeunload` binding + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +- [ ] **Step 1: Add the function** + +In setup.ts: + +```typescript +/** + * Best-effort wipe of sensitive material when the wizard is abandoned. + * Browsers may skip beforeunload if the tab crashes or is killed; JS + * strings (passphrase, API token) are also GC-only. Zero-fills the + * reachable Uint8Array fields; strings are nulled out but cannot be + * truly zeroized. + */ +function clearWizardState(): void { + if (state.carrierImageBytes) state.carrierImageBytes.fill(0); + if (state.referenceImageBytes) state.referenceImageBytes.fill(0); + if (state.referenceImageBytesAttach) state.referenceImageBytesAttach.fill(0); + // Reset every field of `state` to its initial value. + state.passphrase = ''; + state.apiToken = ''; + // … etc. — null/zero every field WizardState exposes +} +``` + +- [ ] **Step 2: Bind to beforeunload** + +In the wizard's bootstrap code: + +```typescript +window.addEventListener('beforeunload', clearWizardState); +``` + +- [ ] **Step 3: Call from `goto('mode')`** + +In the `goto()` function: + +```typescript +function goto(id: StepId): void { + if (id === 'mode') clearWizardState(); + state.currentStep = id; + rerender(); +} +``` + +- [ ] **Step 4: Hold commit (bundle with Task 3.7)** + +--- + +### Task 3.7: Update setup tests + add `clearWizardState` test + +**Files:** +- Modify: `extension/src/setup/__tests__/setup.test.ts` + +- [ ] **Step 1: Read existing setup tests** + +```bash +cd extension && cat src/setup/__tests__/setup.test.ts +``` + +Determine what's asserted today and what needs to change. + +- [ ] **Step 2: Rewrite assertions against the step-registry shape** + +Tests that previously poked at `renderStep0` / `attachStep0` should now assert on the `STEPS` array's shape: + +```typescript +import { STEPS } from '../setup'; + +describe('setup step registry', () => { + it('has the six expected steps in order', () => { + expect(STEPS.map((s) => s.id)).toEqual([ + 'mode', 'host', 'connection', 'vault', 'device', 'done', + ]); + }); + + it('each step renders non-empty HTML and returns a teardown', () => { + for (const step of STEPS) { + const html = step.render({ state: makeState(), rerender: vi.fn(), goto: vi.fn() }); + expect(html.length).toBeGreaterThan(0); + // attach test deferred — would need a DOM container + } + }); +}); +``` + +You'll need to `export { STEPS }` from `setup.ts` (or `export const __test__ = { STEPS, clearWizardState }` for test-only exposure if you don't want to expose to production). + +- [ ] **Step 3: Add `clearWizardState` test** + +```typescript +describe('clearWizardState', () => { + it('zero-fills Uint8Array fields', () => { + const state = makeWizardState({ + carrierImageBytes: new Uint8Array([1, 2, 3, 4]), + }); + __test__.clearWizardState(state); + expect(Array.from(state.carrierImageBytes!)).toEqual([0, 0, 0, 0]); + }); +}); +``` + +- [ ] **Step 4: Run tests** + +```bash +cd extension && npx vitest run src/setup/__tests__/setup.test.ts +``` + +Expected: all pass. + +- [ ] **Step 5: Run full suite to ensure no other surface broke** + +```bash +cd extension && npx vitest run +``` + +- [ ] **Step 6: Commit Tasks 3.4-3.7 as one cohesive commit** + +```bash +cd extension/.. && git add extension/src/setup/setup.ts extension/src/setup/__tests__/setup.test.ts +git commit -m "refactor(ext/setup): SW migration + step registry + clearWizardState (Plan C Phase 3) + +Removes setup.ts's direct WASM orchestration entirely. The wizard now +calls sendMessage({ type: 'create_vault' | 'attach_vault' }) for the +crypto work. The six renderStepN/attachStepN pairs collapse into the +SetupStep registry. clearWizardState() wipes sensitive Uint8Array +fields on beforeunload and on goto('mode'). + +setup.ts drops from ~1220 LOC to ~500 LOC. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Phase 4 — Split `vault.ts` + lift `vault_locked` channel (P1.5) + +**Effort:** M. **Depends on:** Phase 1. + +### Task 4.1: Create `vault-shell.ts` + +**Files:** +- Create: `extension/src/vault/vault-shell.ts` +- Read: `extension/src/vault/vault.ts` (current ~1037 LOC) + +- [ ] **Step 1: Identify the shell concerns in current vault.ts** + +The shell owns: +- DOM scaffolding (the `
` skeleton). +- Color-scheme application (reads `chrome.storage.sync.password_display_scheme` and applies CSS variables). +- `chrome.runtime.onMessage` wiring (for `session_expired` and similar SW push events). +- `applyShellViewClass` (sets `data-view` attribute on the shell). + +Grep for each concern in current vault.ts: + +```bash +cd extension && grep -n "applyShellViewClass\|onMessage\|password_display_scheme\|vault-shell" src/vault/vault.ts | head -10 +``` + +- [ ] **Step 2: Create the file with the migrated functions** + +`extension/src/vault/vault-shell.ts`: + +```typescript +// DOM scaffolding + color-scheme + onMessage wiring for the vault tab. +// Migrated from vault.ts as part of Plan C Phase 4. + +import { applyColorScheme } from '../shared/password-coloring'; + +export function renderShell(app: HTMLElement): void { + // Paste the shell-scaffolding innerHTML from vault.ts:NNN-NNN + app.innerHTML = `…`; +} + +export function applyShellViewClass(view: string): void { + // Paste from vault.ts:NNN-NNN +} + +export function wireShellMessageListener(onSessionExpired: () => void): () => void { + const handler = (msg: { type: string }) => { + if (msg?.type === 'session_expired') onSessionExpired(); + }; + chrome.runtime.onMessage.addListener(handler); + return () => chrome.runtime.onMessage.removeListener(handler); +} + +// Color-scheme apply on boot +export async function applyVaultColorScheme(): Promise { + // Paste from vault.ts:NNN-NNN +} +``` + +- [ ] **Step 3: Remove the migrated code from vault.ts** + +Delete the corresponding lines, leaving import + call sites in vault.ts. + +- [ ] **Step 4: Build to verify** + +```bash +cd extension && npx tsc --noEmit +``` + +- [ ] **Step 5: Run vitest** + +```bash +cd extension && npx vitest run +``` + +Expected: all tests pass (no behavior change). + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/vault/vault-shell.ts extension/src/vault/vault.ts +git commit -m "refactor(ext/vault): extract vault-shell.ts (Plan C Phase 4) + +DOM scaffolding, color-scheme apply, onMessage wiring all live in their +own module. vault.ts shrinks by ~80 LOC. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.2: Create `vault-sidebar.ts` + +**Files:** +- Create: `extension/src/vault/vault-sidebar.ts` +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Identify sidebar concerns** + +The sidebar owns: +- `renderSidebarCategories` (the type-category nav rendering). +- Search input handling (currently NOT debounced — DEV-C P2). +- Bottom nav buttons (add, trash, devices, settings, lock). +- Global keydown shortcuts (search focus on `/`, etc.). + +```bash +cd extension && grep -n "renderSidebarCategories\|sidebar-search\|vault-sidebar__\|keydown" src/vault/vault.ts | head -15 +``` + +- [ ] **Step 2: Create the file with debounced search** + +`extension/src/vault/vault-sidebar.ts`: + +```typescript +// Sidebar — categories nav, search input with 80ms debounce, bottom nav. +// Migrated from vault.ts as part of Plan C Phase 4. Adds the debounce per +// DEV-C P2 (vault.ts:648-695 ran the full filter on every keystroke). + +const SEARCH_DEBOUNCE_MS = 80; + +export function renderSidebarCategories(/* args */): void { + // Paste from vault.ts:NNN-NNN +} + +export function wireSidebar(/* args */): () => void { + // Paste from vault.ts:NNN-NNN, with the search input wrapped in debounce: + + const searchEl = document.getElementById('vault-search') as HTMLInputElement | null; + let searchTimer: number | undefined; + searchEl?.addEventListener('input', () => { + if (searchTimer !== undefined) clearTimeout(searchTimer); + searchTimer = window.setTimeout(() => { + onSearchChange(searchEl.value); + }, SEARCH_DEBOUNCE_MS); + }); + + // … bottom nav buttons, keydown shortcuts (unchanged) + + return function teardownSidebar() { + if (searchTimer !== undefined) clearTimeout(searchTimer); + // … other teardown + }; +} +``` + +- [ ] **Step 3: Remove the migrated code from vault.ts** + +- [ ] **Step 4: Build + test** + +```bash +cd extension && npx tsc --noEmit && npx vitest run +``` + +- [ ] **Step 5: Commit** + +```bash +cd extension/.. && git add extension/src/vault/vault-sidebar.ts extension/src/vault/vault.ts +git commit -m "refactor(ext/vault): extract vault-sidebar.ts with debounced search (Plan C Phase 4) + +Migrates sidebar concerns out of vault.ts. The search input gets an 80ms +trailing-edge debounce (P2 fix — was firing on every keystroke). + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.3: Create `vault-list.ts` + +**Files:** +- Create: `extension/src/vault/vault-list.ts` +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Identify list concerns** + +The list pane owns: +- `renderListPane(entries, activeSelection)` (the row rendering). +- Per-type glyph icon mapping (from `shared/glyphs.ts`). +- Row click handler (opens the drawer — but the drawer handler lives in vault-drawer.ts, so the list dispatches an event or calls a passed-in callback). + +- [ ] **Step 2: Create the file** + +`extension/src/vault/vault-list.ts`: + +```typescript +import { GLYPH_LOGIN, /* … all per-type glyphs */ } from '../shared/glyphs'; +import type { ItemId, ManifestEntry, ItemType } from '../shared/types'; + +export function renderListPane( + pane: HTMLElement, + entries: Array<[ItemId, ManifestEntry]>, + activeSelection: ItemId | null, + onRowClick: (id: ItemId) => void, +): void { + // Paste from vault.ts:NNN-NNN +} + +function glyphForType(t: ItemType): string { + switch (t) { + case 'login': return GLYPH_LOGIN; + // … + } +} +``` + +- [ ] **Step 3: Remove from vault.ts, build, test, commit** + +```bash +cd extension/.. && git add extension/src/vault/vault-list.ts extension/src/vault/vault.ts +git commit -m "refactor(ext/vault): extract vault-list.ts (Plan C Phase 4) + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.4: Create `vault-drawer.ts` with `ensureDrawerClosedForRoute` + +**Files:** +- Create: `extension/src/vault/vault-drawer.ts` +- Create: `extension/src/vault/__tests__/drawer-state.test.ts` +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Identify drawer concerns** + +The drawer owns: +- `openDrawer(itemId)` / `closeDrawer()`. +- `renderDrawer(item)` (the 2-column field grid). +- Drawer event wiring (esc to close, ✕ button). +- `state.drawerOpen` reset (the P2 leak fix lives here). + +- [ ] **Step 2: Write the drawer-state test first** + +`extension/src/vault/__tests__/drawer-state.test.ts`: + +```typescript +import { describe, expect, it } from 'vitest'; +import { ensureDrawerClosedForRoute } from '../vault-drawer'; + +describe('ensureDrawerClosedForRoute', () => { + it('closes the drawer when navigating to trash', () => { + const state = { drawerOpen: true, selectedItem: 'abc' as never }; + ensureDrawerClosedForRoute(state, { view: 'trash' }); + expect(state.drawerOpen).toBe(false); + }); + + it('leaves drawer open when navigating between list and detail', () => { + const state = { drawerOpen: true, selectedItem: 'abc' as never }; + ensureDrawerClosedForRoute(state, { view: 'detail' }); + expect(state.drawerOpen).toBe(true); + }); + + it('does nothing when drawer was already closed', () => { + const state = { drawerOpen: false, selectedItem: null }; + ensureDrawerClosedForRoute(state, { view: 'devices' }); + expect(state.drawerOpen).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run test (expect FAIL — function doesn't exist)** + +```bash +cd extension && npx vitest run src/vault/__tests__/drawer-state.test.ts +``` + +- [ ] **Step 4: Create the file** + +`extension/src/vault/vault-drawer.ts`: + +```typescript +import type { RouterState, Route } from './vault'; + +export function openDrawer(/* args */): void { /* paste */ } +export function closeDrawer(state: RouterState): void { + state.drawerOpen = false; + // … DOM updates +} +export function renderDrawer(/* args */): void { /* paste */ } + +/** + * The renderPane switch calls this before any non-list view to prevent + * drawer state leaking across navigation (P2 fix for vault.ts:495-536). + */ +export function ensureDrawerClosedForRoute( + state: RouterState, + route: Route, +): void { + const drawerKeepingViews = new Set(['list', 'detail']); + if (!drawerKeepingViews.has(route.view)) { + state.drawerOpen = false; + } +} +``` + +- [ ] **Step 5: Wire `ensureDrawerClosedForRoute` into vault.ts's renderPane switch** + +In vault.ts: + +```typescript +function renderPane(): void { + const route = parseHash(); + ensureDrawerClosedForRoute(state, route); + // … existing switch +} +``` + +- [ ] **Step 6: Run test (expect PASS)** + +- [ ] **Step 7: Run full suite** + +- [ ] **Step 8: Commit** + +```bash +cd extension/.. && git add extension/src/vault/vault-drawer.ts extension/src/vault/__tests__/drawer-state.test.ts extension/src/vault/vault.ts +git commit -m "refactor(ext/vault): extract vault-drawer.ts + ensureDrawerClosedForRoute (Plan C Phase 4) + +P2 fix: drawer state no longer leaks across navigation (vault.ts:495-536 +called out the bug). ensureDrawerClosedForRoute runs in the renderPane +switch before any non-list view. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.5: Create `vault-form-wrapper.ts` + +**Files:** +- Create: `extension/src/vault/vault-form-wrapper.ts` +- Modify: `extension/src/vault/vault.ts` +- Modify: `extension/src/vault/__tests__/form-wrapper.test.ts` (update import path) + +- [ ] **Step 1: Identify form-wrapper concerns** + +The form-wrapper owns: +- `renderFormWrapped(app, mode)` (already at vault.ts:761 after Plan B Phase 2B). +- The `__test__` export. +- The sticky save bar + header + dirty-state wiring. + +- [ ] **Step 2: Move the code** + +Cut the entire `renderFormWrapped` function + the `__test__` export from vault.ts and paste into `extension/src/vault/vault-form-wrapper.ts`. Adjust imports as needed. + +- [ ] **Step 3: Update form-wrapper test import** + +In `extension/src/vault/__tests__/form-wrapper.test.ts`, change: + +```typescript +import { __test__ } from '../vault'; +``` + +To: + +```typescript +import { __test__ } from '../vault-form-wrapper'; +``` + +- [ ] **Step 4: Build + test** + +- [ ] **Step 5: Commit** + +```bash +cd extension/.. && git add extension/src/vault/vault-form-wrapper.ts extension/src/vault/vault.ts extension/src/vault/__tests__/form-wrapper.test.ts +git commit -m "refactor(ext/vault): extract vault-form-wrapper.ts (Plan C Phase 4) + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.6: Trim `vault.ts` to ~200 LOC + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Measure current LOC** + +```bash +cd extension && wc -l src/vault/vault.ts +``` + +Expected: ~700-800 LOC after Tasks 4.1-4.5 (started at 1037). + +- [ ] **Step 2: Identify what should remain** + +The retained content per the spec: +- `RouterState` declaration. +- Hash parsing (`parseHash`, `setHash`). +- `loadManifest`. +- `render()` entry point. +- `renderPane()` switch. +- The imports that wire the modules. + +Anything else gets pushed into the right module from Tasks 4.1-4.5. + +- [ ] **Step 3: Sweep remaining inline code into modules** + +For each remaining block of inline code in vault.ts that isn't routing/state, identify its home: +- Component lifecycle helpers (`teardownPaneComponents`) → keep in vault.ts since they coordinate across modules. +- The `vault_locked` RPC intercept at vault.ts:47-74 → lift in Task 4.7; not this task. +- Anything else: relocate. + +- [ ] **Step 4: Verify the target LOC** + +```bash +cd extension && wc -l src/vault/vault.ts +``` + +Target: ≤ ~250 LOC (spec says "~200 LOC of routing + state"). + +- [ ] **Step 5: Build + test** + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/vault/vault.ts +git commit -m "refactor(ext/vault): trim vault.ts to routing + state (Plan C Phase 4) + +Final pass after Tasks 4.1-4.5. vault.ts now owns RouterState, hash +parsing, loadManifest, the render() entry, and the renderPane switch +only. All pane-specific logic moved to the per-concern modules. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 4.7: Lift `vault_locked` RPC intercept into `shared/state.ts` + +**Files:** +- Modify: `extension/src/shared/state.ts` +- Modify: `extension/src/vault/vault.ts` (delete the old intercept) +- Create: `extension/src/shared/__tests__/state-vault-locked.test.ts` + +- [ ] **Step 1: Read the current intercept** + +```bash +cd extension && sed -n '47,74p' src/vault/vault.ts +``` + +Record the body. It typically wraps `sendMessage` and on `{ ok: false, error: 'vault_locked' }` flips local state to a locked screen. + +- [ ] **Step 2: Write the failing test** + +`extension/src/shared/__tests__/state-vault-locked.test.ts`: + +```typescript +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + registerHost, __resetHostForTests, sendMessage, +} from '../state'; +import type { StateHost } from '../state'; + +function makeHost(): StateHost { + return { + getState: () => ({ view: 'list', loading: false, error: null, + entries: [], selectedItem: null, searchQuery: '', + newType: null } as never), + setState: vi.fn(), + navigate: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ ok: false, error: 'vault_locked' }), + escapeHtml: (s) => s, + popOutToTab: vi.fn(), + isInTab: () => false, + openVaultTab: vi.fn(), + }; +} + +describe('state.sendMessage vault_locked intercept', () => { + beforeEach(() => __resetHostForTests()); + + it('flips host state to unlock view on vault_locked response', async () => { + const host = makeHost(); + registerHost(host); + await sendMessage({ type: 'list_items' }); + expect(host.navigate).toHaveBeenCalledWith('unlock', expect.anything()); + }); + + it('does NOT intercept on the unlock request itself', async () => { + const host = makeHost(); + registerHost(host); + await sendMessage({ type: 'unlock', passphrase: 'x' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); + + it('does NOT intercept on is_unlocked', async () => { + const host = makeHost(); + registerHost(host); + await sendMessage({ type: 'is_unlocked' }); + expect(host.navigate).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 3: Run test (expect FAIL — wrapper is a pass-through from Phase 1)** + +- [ ] **Step 4: Fill the wrapper body in `shared/state.ts`** + +Replace the pass-through `sendMessage` in `shared/state.ts`: + +```typescript +const VAULT_LOCKED_INTERCEPT_BYPASS: ReadonlySet = new Set([ + 'unlock', 'is_unlocked', +]); + +export async function sendMessage(request: Request): Promise { + if (!host) throw new Error('No state host registered'); + const resp = await host.sendMessage(request); + if (!resp.ok && resp.error === 'vault_locked' + && !VAULT_LOCKED_INTERCEPT_BYPASS.has(request.type)) { + host.navigate('unlock', { error: 'Vault locked. Re-enter passphrase.' }); + } + return resp; +} +``` + +- [ ] **Step 5: Delete the old intercept from vault.ts** + +Remove lines 47-74 (or whatever range now corresponds after Tasks 4.1-4.6). + +- [ ] **Step 6: Run the new test (expect PASS)** + +- [ ] **Step 7: Run full suite** + +Expected: all pass, including popup's existing `session_expired` handling (since the SW still dispatches that event for legacy consumers; the wrapper is the new path). + +- [ ] **Step 8: Commit** + +```bash +cd extension/.. && git add extension/src/shared/state.ts extension/src/vault/vault.ts extension/src/shared/__tests__/state-vault-locked.test.ts +git commit -m "refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4) + +Both popup and vault tab now consume vault_locked through the +sendMessage wrapper instead of duplicate channels (popup had +session_expired event listener; vault had inline RPC intercept). +The SW still dispatches session_expired for the popup's existing +listener; this is the migration cycle the spec calls out. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +## Phase 5 — Extension P2 cluster + +**Effort:** M. **Depends on:** none. + +### Task 5.1: Invert inactivity-timer reset rule + +**Files:** +- Modify: `extension/src/service-worker/index.ts` +- Modify: `extension/src/service-worker/session-timer.ts` + +- [ ] **Step 1: Locate the current rule** + +```bash +cd extension && sed -n '70,85p' src/service-worker/index.ts +``` + +The current behavior is "reset on popup-only messages." We want "reset on all messages except a documented exclusion set." + +- [ ] **Step 2: Define the exclusion set in `session-timer.ts`** + +Append to `extension/src/service-worker/session-timer.ts`: + +```typescript +/** + * Content-callable message types that should NOT reset the inactivity timer. + * + * Rationale: a content script reading available autofill candidates is a + * passive query — it shouldn't keep the vault alive indefinitely while the + * user isn't actually interacting with it. + * + * Today this is the only known passive read; if a future content message + * is also passive, add it here with a one-line justification. + */ +export const READ_ONLY_CONTENT_CALLABLE: ReadonlySet = new Set([ + 'get_autofill_candidates', +]); +``` + +- [ ] **Step 3: Invert the rule in `index.ts`** + +Change the existing block (lines ~70-85) from "reset only if popup-only" to: + +```typescript +import { READ_ONLY_CONTENT_CALLABLE } from './session-timer'; + +// In the onMessage handler, after route dispatch: +if (!READ_ONLY_CONTENT_CALLABLE.has(msg.type)) { + sessionTimer.reset(); +} +``` + +- [ ] **Step 4: Update existing session-timer test** + +In `extension/src/service-worker/__tests__/session-timer.test.ts`, add: + +```typescript +it('popup-only message resets the timer', async () => { + // route({ type: 'list_items' }, popupSender) + // assert sessionTimer.reset was called +}); + +it('content-callable get_autofill_candidates does NOT reset', async () => { + // route({ type: 'get_autofill_candidates' }, contentSender) + // assert sessionTimer.reset was NOT called +}); + +it('content-callable capture_save_login DOES reset', async () => { + // route({ type: 'capture_save_login', ... }, contentSender) + // assert sessionTimer.reset was called (write op = active use) +}); +``` + +- [ ] **Step 5: Run tests** + +- [ ] **Step 6: Commit** + +```bash +cd extension/.. && git add extension/src/service-worker/index.ts extension/src/service-worker/session-timer.ts extension/src/service-worker/__tests__/session-timer.test.ts +git commit -m "fix(ext/sw): inactivity timer resets on all non-passive messages (Plan C Phase 5) + +DEV-C P2: an active autofiller never opens the popup, so under the old +rule it got force-locked despite continuous use. Inverts the rule: +reset on all messages except a documented exclusion set (only +get_autofill_candidates today). + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 5.2: Clear `state.gitHost` on session expiry + +**Files:** +- Modify: `extension/src/service-worker/index.ts` + +- [ ] **Step 1: Locate `sessionTimer.onExpired` callback** + +```bash +cd extension && sed -n '48,62p' src/service-worker/index.ts +``` + +- [ ] **Step 2: Add `state.gitHost = null`** + +In the onExpired callback, alongside the existing `state.manifest = null`: + +```typescript +sessionTimer.onExpired(() => { + state.manifest = null; + state.gitHost = null; // Plan C Phase 5: don't leak the cached client + clearCurrent(); + // … existing notifications +}); +``` + +- [ ] **Step 3: Add or extend a test** + +In an appropriate test file: + +```typescript +it('session expiry clears state.gitHost', async () => { + state.gitHost = makeFakeGitHost(); + // trigger expiry + expect(state.gitHost).toBeNull(); +}); +``` + +- [ ] **Step 4: Run + commit** + +```bash +cd extension/.. && git add extension/src/service-worker/index.ts +git commit -m "fix(ext/sw): clear state.gitHost on session expiry (Plan C Phase 5) + +DEV-C P2: expiry cleared manifest but left the cached git-host client. +The initializer rebuilds gitHost on demand, so clearing here is safe. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 5.3: Extract `teardownSettingsCommon` + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` +- Modify: `extension/src/popup/components/settings-vault.ts` + +- [ ] **Step 1: Read both teardown bodies** + +```bash +cd extension && sed -n '56,65p' src/popup/components/settings.ts +sed -n '15,22p' src/popup/components/settings-vault.ts +``` + +Identify the common steps: `closeGeneratorPanel()`, `document.removeEventListener('keydown', activeKeyHandler)`, etc. + +- [ ] **Step 2: Add `teardownSettingsCommon` to `settings.ts`** + +```typescript +/** + * Common cleanup invoked by both the device-settings teardown + * (settings.ts) and the vault-settings teardown (settings-vault.ts). + * Centralized to avoid the "regression class with known prior leaks" + * DEV-C P2 flagged. + */ +export function teardownSettingsCommon(): void { + closeGeneratorPanel(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } +} +``` + +- [ ] **Step 3: Replace inline cleanup in both files** + +In `settings.ts`'s existing teardown: + +```typescript +export function teardownSettings(): void { + teardownSettingsCommon(); + // … any settings.ts-specific cleanup +} +``` + +In `settings-vault.ts`: + +```typescript +import { teardownSettingsCommon } from './settings'; + +export function teardown(): void { + teardownSettingsCommon(); + // … any settings-vault.ts-specific cleanup +} +``` + +- [ ] **Step 4: Build + test (existing tests should still pass)** + +- [ ] **Step 5: Commit** + +```bash +cd extension/.. && git add extension/src/popup/components/settings.ts extension/src/popup/components/settings-vault.ts +git commit -m "refactor(ext/popup): extract teardownSettingsCommon (Plan C Phase 5) + +DEV-C P2: settings.ts:56-65 and settings-vault.ts:15-22 had near- +identical cleanup paths. Single source for closeGeneratorPanel + +activeKeyHandler removal. + +Co-Authored-By: Claude Opus 4.7 " +``` + +--- + +### Task 5.4: Switch `Promise.all` to `Promise.allSettled` in devices / trash + +**Files:** +- Modify: `extension/src/popup/components/devices.ts` +- Modify: `extension/src/popup/components/trash.ts` + +- [ ] **Step 1: Locate the `Promise.all` calls** + +```bash +cd extension && grep -n "Promise.all\b" src/popup/components/devices.ts src/popup/components/trash.ts +``` + +- [ ] **Step 2: Replace in devices.ts** + +Find the `Promise.all([sendMessage({type:'list_devices'}), sendMessage({type:'list_revoked'})])` call (around line 47-50 per the spec). + +Replace with: + +```typescript +const [devicesResp, revokedResp] = await Promise.allSettled([ + sendMessage({ type: 'list_devices' }), + sendMessage({ type: 'list_revoked' }), +]); + +const devices = devicesResp.status === 'fulfilled' && devicesResp.value.ok + ? (devicesResp.value.data as { devices: Device[] }).devices + : (renderLoadErrorSlot('devices'), []); + +const revoked = revokedResp.status === 'fulfilled' && revokedResp.value.ok + ? (revokedResp.value.data as { revoked: RevokedEntry[] }).revoked + : (renderLoadErrorSlot('revoked'), []); +``` + +Add a `renderLoadErrorSlot(label: string): void` helper that inserts a "couldn't load