# 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 <noreply@anthropic.com>"
```

---

### 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<PopupState>): void;
  navigate(view: View, extras?: Partial<PopupState>): void;
  sendMessage(request: Request): Promise<Response>;
  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<PopupState>): void {
  if (!host) throw new Error('No state host registered');
  host.setState(partial);
}

export function navigate(view: View, extras?: Partial<PopupState>): 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<Response> {
  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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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<PopupState>): 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 <noreply@anthropic.com>"
```

---

## 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<DeviceSettings> {
  // 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<void> {
  await chrome.storage.local.set({ device_settings: settings });
}

export async function loadBlacklist(): Promise<string[]> {
  // 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<void> {
  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<string, unknown> = {}) {
  const store: Record<string, unknown> = { ...initial };
  (global as { chrome: unknown }).chrome = {
    storage: {
      local: {
        get: vi.fn((keys: string | string[]) => {
          const arr = Array.isArray(keys) ? keys : [keys];
          const out: Record<string, unknown> = {};
          for (const k of arr) if (k in store) out[k] = store[k];
          return Promise.resolve(out);
        }),
        set: vi.fn((kv: Record<string, unknown>) => {
          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 <noreply@anthropic.com>"
```

---

## 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 <noreply@anthropic.com>"
```

---

### 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<CreateVaultResponse | { ok: false; error: string }> {
  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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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<SetupStep> = [
  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 <noreply@anthropic.com>"
```

---

## 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 `<div id="vault-shell">` 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<void> {
  // 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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<string> = new Set([
  'unlock', 'is_unlocked',
]);

export async function sendMessage(request: Request): Promise<Response> {
  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 <noreply@anthropic.com>"
```

---

## 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<string> = 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <noreply@anthropic.com>"
```

---

### 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 <label>" message into the appropriate DOM slot.

- [ ] **Step 3: Same for trash.ts (line 39-46)**

```typescript
const [trashedResp, settingsResp] = await Promise.allSettled([
  sendMessage({ type: 'list_trashed' }),
  sendMessage({ type: 'get_settings' }),
]);

// … defensive per-slot rendering
```

- [ ] **Step 4: Update existing devices.test.ts and trash.test.ts**

Add cases:

```typescript
it('renders devices when revoked list fails', async () => {
  // mockListPair([{name:'foo',...}], <reject>)
  // assert device row rendered
  // assert revoked-load-error slot rendered
});
```

- [ ] **Step 5: Run tests + commit**

```bash
cd extension/.. && git add extension/src/popup/components/devices.ts extension/src/popup/components/trash.ts $(git diff --name-only -- extension/src/popup/components/__tests__/)
git commit -m "fix(ext/popup): defensive Promise.allSettled in devices + trash (Plan C Phase 5)

DEV-C P2: Promise.all meant one rejected RPC failed the whole render.
allSettled + per-slot fallback keeps the surface usable when one feed
is down.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

### Task 5.5: Debounce MutationObserver `scan()` in detector.ts

**Files:**
- Modify: `extension/src/content/detector.ts`

- [ ] **Step 1: Locate the MutationObserver callback**

```bash
cd extension && sed -n '92,108p' src/content/detector.ts
```

The current shape:

```typescript
const observer = new MutationObserver(() => scan());
```

- [ ] **Step 2: Wrap in 200ms debounce**

Replace with:

```typescript
const SCAN_DEBOUNCE_MS = 200;
let scanTimer: number | undefined;

function scheduleScan(): void {
  if (scanTimer !== undefined) clearTimeout(scanTimer);
  scanTimer = window.setTimeout(() => {
    scanTimer = undefined;
    scan();
  }, SCAN_DEBOUNCE_MS);
}

const observer = new MutationObserver(scheduleScan);
```

- [ ] **Step 3: Add a test if a test harness exists for detector**

If `extension/src/content/__tests__/detector.test.ts` exists, add:

```typescript
it('debounces rapid MutationObserver fires', async () => {
  // … set up a JSDOM with the detector mounted
  // … fire 10 mutations in quick succession
  // … advance timers 250ms
  // … assert scan was called exactly once
});
```

If no test harness exists, skip and rely on manual verification on a real SPA page.

- [ ] **Step 4: Build + commit**

```bash
cd extension/.. && git add extension/src/content/detector.ts $(git diff --name-only -- extension/src/content/__tests__/ 2>/dev/null)
git commit -m "perf(ext/content): debounce MutationObserver scan() to 200ms (Plan C Phase 5)

DEV-C P2: SPA churn was re-running the full scan many times per second.
Trailing-edge debounce coalesces bursts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Phase 6 — `get_vault_status` SW message + sidebar status indicator

**Effort:** S-M. **Depends on:** Phase 4.

### Task 6.1: Implement `get_vault_status` SW handler

**Files:**
- Modify: `extension/src/service-worker/vault.ts` (handler) — note `get_vault_status` type was added to `messages.ts` in Task 3.1.
- Modify: `extension/src/service-worker/router/popup-only.ts` (dispatch)
- Modify: `extension/src/service-worker/git-host.ts` (cache fields)
- Create: `extension/src/service-worker/__tests__/vault-status.test.ts`

- [ ] **Step 1: Add cached fields to git-host state**

In `extension/src/service-worker/git-host.ts` (or wherever the `GitHost` interface lives), add:

```typescript
export interface GitHost {
  // … existing
  lastSyncAt: number | null;
  ahead: number;
  behind: number;
}
```

Initialize to `null`/`0`/`0` on host construction.

- [ ] **Step 2: Update the `sync` handler to populate them**

Find the existing `sync` handler in `service-worker/vault.ts` (or similar). After a successful sync, set:

```typescript
state.gitHost.lastSyncAt = Date.now();
state.gitHost.ahead = syncResult.ahead ?? 0;
state.gitHost.behind = syncResult.behind ?? 0;
```

- [ ] **Step 3: Write the failing handler test**

`extension/src/service-worker/__tests__/vault-status.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { handleGetVaultStatus } from '../vault';

describe('get_vault_status', () => {
  it('returns zeros when never synced', async () => {
    const state = makeStateWithGitHost({
      lastSyncAt: null, ahead: 0, behind: 0,
    });
    const resp = await handleGetVaultStatus(state);
    expect(resp).toEqual({
      ok: true,
      data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 },
    });
  });

  it('reflects cached sync state', async () => {
    const state = makeStateWithGitHost({
      lastSyncAt: 1234567890, ahead: 3, behind: 1,
    });
    state.manifest = { items: { /* 5 entries with trashed_at:null */ } };
    const resp = await handleGetVaultStatus(state);
    expect(resp.data.lastSyncAt).toBe(1234567890);
    expect(resp.data.ahead).toBe(3);
    expect(resp.data.behind).toBe(1);
    expect(resp.data.pendingItems).toBe(5);
  });

  it('does NOT call into the network', async () => {
    // Stub gitHost.fetch / .push to throw; assert handler returns ok regardless
  });
});
```

- [ ] **Step 4: Implement `handleGetVaultStatus`**

In `extension/src/service-worker/vault.ts`:

```typescript
import type { GetVaultStatusResponse } from '../shared/messages';

export async function handleGetVaultStatus(
  state: RouterState,
): Promise<GetVaultStatusResponse | { ok: false; error: string }> {
  if (!state.gitHost) return { ok: false, error: 'vault_locked' };
  const pendingItems = state.manifest
    ? Object.values(state.manifest.items).filter((e) => !e.trashed_at).length
    : 0;
  return {
    ok: true,
    data: {
      ahead: state.gitHost.ahead,
      behind: state.gitHost.behind,
      lastSyncAt: state.gitHost.lastSyncAt,
      pendingItems,
    },
  };
}
```

- [ ] **Step 5: Wire dispatch in popup-only.ts**

```typescript
case 'get_vault_status':
  return handleGetVaultStatus(state);
```

- [ ] **Step 6: Run test (expect PASS)**

- [ ] **Step 7: Commit**

```bash
cd extension/.. && git add extension/src/service-worker/vault.ts extension/src/service-worker/git-host.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/__tests__/vault-status.test.ts
git commit -m "feat(ext/sw): get_vault_status handler (Plan C Phase 6)

Returns cached ahead/behind/lastSyncAt from state.gitHost plus a live
pendingItems count from the manifest. Does NOT call into the network —
sync is explicit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

### Task 6.2: Create `vault-status.ts` renderer

**Files:**
- Create: `extension/src/vault/vault-status.ts`
- Create: `extension/src/vault/__tests__/status-indicator.test.ts`

- [ ] **Step 1: Write the failing renderer test**

`extension/src/vault/__tests__/status-indicator.test.ts`:

```typescript
import { describe, expect, it, vi } from 'vitest';
import { renderStatusIndicator } from '../vault-status';

describe('vault status indicator', () => {
  it('renders "in sync" when ahead/behind/pending all zero', () => {
    const el = document.createElement('div');
    renderStatusIndicator(el, {
      ahead: 0, behind: 0, lastSyncAt: Date.now(), pendingItems: 0,
    });
    expect(el.textContent).toMatch(/in sync/i);
  });

  it('renders "N ahead" when ahead is non-zero', () => {
    const el = document.createElement('div');
    renderStatusIndicator(el, {
      ahead: 3, behind: 0, lastSyncAt: Date.now(), pendingItems: 0,
    });
    expect(el.textContent).toMatch(/3 ahead/i);
  });

  it('renders "N pending" when pendingItems is non-zero', () => {
    const el = document.createElement('div');
    renderStatusIndicator(el, {
      ahead: 0, behind: 0, lastSyncAt: Date.now(), pendingItems: 5,
    });
    expect(el.textContent).toMatch(/5 pending/i);
  });

  it('renders "never synced" when lastSyncAt is null', () => {
    const el = document.createElement('div');
    renderStatusIndicator(el, {
      ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0,
    });
    expect(el.textContent).toMatch(/never synced/i);
  });
});
```

- [ ] **Step 2: Create the renderer**

`extension/src/vault/vault-status.ts`:

```typescript
import { GLYPH_SYNCED, GLYPH_AHEAD, GLYPH_BEHIND, GLYPH_PENDING }
  from '../shared/glyphs';
import { relativeTime } from '../shared/relative-time';

interface VaultStatus {
  ahead: number;
  behind: number;
  lastSyncAt: number | null;
  pendingItems: number;
}

export function renderStatusIndicator(el: HTMLElement, status: VaultStatus): void {
  const ts = status.lastSyncAt
    ? `last sync ${relativeTime(status.lastSyncAt, Date.now())}`
    : 'never synced';

  const parts: string[] = [];
  if (status.pendingItems > 0) {
    parts.push(`${GLYPH_PENDING} ${status.pendingItems} pending`);
  }
  if (status.ahead > 0) parts.push(`${GLYPH_AHEAD} ${status.ahead} ahead`);
  if (status.behind > 0) parts.push(`${GLYPH_BEHIND} ${status.behind} behind`);
  if (parts.length === 0) parts.push(`${GLYPH_SYNCED} in sync`);

  el.innerHTML = `
    <div class="vault-status">
      <div class="vault-status__state">${parts.join(' · ')}</div>
      <div class="vault-status__ts">${ts}</div>
    </div>
  `;
}
```

You may need to add the four glyphs (`GLYPH_SYNCED`, `GLYPH_AHEAD`, `GLYPH_BEHIND`, `GLYPH_PENDING`) to `shared/glyphs.ts` if they don't exist. Use existing glyph-family conventions.

- [ ] **Step 3: Run test (expect PASS)**

- [ ] **Step 4: Commit**

```bash
cd extension/.. && git add extension/src/vault/vault-status.ts extension/src/vault/__tests__/status-indicator.test.ts extension/src/shared/glyphs.ts
git commit -m "feat(ext/vault): vault-status indicator renderer (Plan C Phase 6)

Renders sidebar-footer indicator with ahead/behind/pending state. Pure
DOM; status fetch happens in the wiring layer (Task 6.3).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

### Task 6.3: Wire indicator into sidebar

**Files:**
- Modify: `extension/src/vault/vault-sidebar.ts`

- [ ] **Step 1: Add an indicator slot in the sidebar markup**

Find the sidebar `renderSidebarCategories` (or equivalent) and add a footer slot:

```html
<div class="vault-sidebar__footer">
  <div id="vault-status-slot"></div>
  <button class="btn-icon" id="status-refresh-btn" title="Refresh status">↻</button>
</div>
```

- [ ] **Step 2: Add the wiring**

In `vault-sidebar.ts`'s wire function:

```typescript
import { renderStatusIndicator } from './vault-status';
import { sendMessage } from '../shared/state';

async function refreshStatus(): Promise<void> {
  const resp = await sendMessage({ type: 'get_vault_status' });
  if (!resp.ok) return;
  const slot = document.getElementById('vault-status-slot');
  if (slot) renderStatusIndicator(slot, resp.data);
}

// On sidebar mount:
void refreshStatus();

// On manual refresh button:
document.getElementById('status-refresh-btn')?.addEventListener('click', () => {
  void refreshStatus();
});
```

Do NOT poll on a timer. The spec is explicit: "polls on mount + manual refresh button, not every render."

- [ ] **Step 3: Run full vitest suite**

- [ ] **Step 4: Commit**

```bash
cd extension/.. && git add extension/src/vault/vault-sidebar.ts
git commit -m "feat(ext/vault): wire vault-status into sidebar footer (Plan C Phase 6)

Status refresh happens on sidebar mount and on the manual ↻ button.
No timer polling — matches the spec's no-network-without-user-intent
discipline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

---

## Final Verification

### Task 7.1: Run the full Done-criteria sweep

- [ ] **Step 1: `tsc --noEmit` clean**

```bash
cd extension && npx tsc --noEmit 2>&1 | tail -5
```

Expected: no output (clean).

- [ ] **Step 2: Full vitest suite green**

```bash
cd extension && npx vitest run
```

Expected: all tests pass.

- [ ] **Step 3: Production build clean**

```bash
cd extension && npm run build:all 2>&1 | tail -5
```

Expected: webpack compiles both targets (Chrome + Firefox) with no errors (only the pre-existing 4MB WASM warning).

- [ ] **Step 4: Spec done-criteria checklist**

For each item in the spec's Done criteria (lines 343-368), verify:

- [ ] `shared/state.ts` `StateHost` interface has no `any` in public surface — `grep -c ": any\|<any>" extension/src/shared/state.ts` returns 0.
- [ ] `registerHost` throws on second registration — verified by test in Task 1.5.
- [ ] `__resetHostForTests` exported — verified by import in tests.
- [ ] Router files don't contain duplicated `loadDeviceSettings` / `loadBlacklist` / `saveBlacklist` — `grep -c "function loadDeviceSettings\|function loadBlacklist\|function saveBlacklist" extension/src/service-worker/router/*.ts` returns 0.
- [ ] `itemToManifestEntry` defined once — `grep -rn "function itemToManifestEntry\|export function itemToManifestEntry" extension/src/service-worker/` returns 1.
- [ ] `setup.ts` ≤ 500 LOC — `wc -l extension/src/setup/setup.ts`.
- [ ] `setup.ts` does not import `relicario-wasm` — `grep -c "relicario-wasm" extension/src/setup/setup.ts` returns 0.
- [ ] SW handles all three new messages — `grep -c "case 'create_vault'\|case 'attach_vault'\|case 'get_vault_status'" extension/src/service-worker/router/popup-only.ts` returns 3.
- [ ] `clearWizardState` bound to `beforeunload` — grep visually confirms.
- [ ] `vault.ts` split into 6 modules — `ls extension/src/vault/vault-*.ts` returns 5 (`vault-shell`, `vault-sidebar`, `vault-list`, `vault-drawer`, `vault-form-wrapper`), plus `vault-status.ts` from Phase 6. `wc -l extension/src/vault/vault.ts` returns ≤ ~250.
- [ ] `vault.ts` no longer contains `vault_locked` intercept — `grep -c "vault_locked" extension/src/vault/vault.ts` returns 0.
- [ ] Drawer closes on non-list nav — verified by test in Task 4.4.
- [ ] Sidebar search debounced — `grep "SEARCH_DEBOUNCE_MS" extension/src/vault/vault-sidebar.ts` returns a match.
- [ ] Inactivity timer rule inverted — verified by test in Task 5.1.
- [ ] `state.gitHost = null` on expiry — verified by grep + test.
- [ ] Single `teardownSettingsCommon` — `grep -rn "function teardownSettingsCommon\|export function teardownSettingsCommon" extension/src/popup/components/` returns 1.
- [ ] `devices.ts` and `trash.ts` use `Promise.allSettled` — `grep -c "Promise.allSettled" extension/src/popup/components/devices.ts extension/src/popup/components/trash.ts` returns 2.
- [ ] `detector.ts` debounced — `grep "SCAN_DEBOUNCE_MS\|scheduleScan" extension/src/content/detector.ts` returns a match.
- [ ] `get_vault_status` message exists and is rendered — verified by sidebar mount.
- [ ] No `wasm.d.ts` change — `git diff main -- extension/src/wasm.d.ts` is empty (unless Plan B has been merged in parallel and added its parser exports — in which case those are Plan B's diff, not Plan C's).
- [ ] All `.free()` callsites preceded by `wasm.lock` per Plan A policy — `grep -B 2 "\.free()" extension/src/` and visually confirm each is preceded by a `wasm.lock(...)` call.

- [ ] **Step 5: Final commit (if any verification fixes needed)**

If any final adjustments are needed (typically minor — a missed import path, a forgotten teardown), commit them with:

```bash
cd extension/.. && git commit -m "fix(ext): final verification fixes (Plan C completion)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

- [ ] **Step 6: Update STATUS.md and ROADMAP.md**

Move extension restructure from "Up next" to shipped. Add a "Phase 4 command palette" pointer as the new next item if appropriate.

```bash
cd extension/.. && git add STATUS.md ROADMAP.md
git commit -m "docs: Plan C (extension restructure) complete; update STATUS/ROADMAP

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>"
```

- [ ] **Step 7: Push**

```bash
git push origin main
```

---

## Completion Checklist

- [ ] Phase 1: `StateHost` typed end-to-end (Tasks 1.1-1.5)
- [ ] Phase 2: SW helpers consolidated in `storage.ts` + `vault.ts` (Tasks 2.1-2.3)
- [ ] Phase 3: Setup wizard SW-orchestrated + step registry + `clearWizardState` (Tasks 3.1-3.7)
- [ ] Phase 4: `vault.ts` split into 5 modules + `vault_locked` channel unified (Tasks 4.1-4.7)
- [ ] Phase 5: Five P2 fixes (Tasks 5.1-5.5)
- [ ] Phase 6: `get_vault_status` + sidebar status indicator (Tasks 6.1-6.3)
- [ ] Task 7.1: Final verification + STATUS/ROADMAP update

---

## Notes on execution order

Phase 1 blocks Phases 3 and 4. Phase 4 blocks Phase 6. Phases 2 and 5 are independent of everything else.

**Recommended sequence for sequential execution:**
1. Phase 1 (the typed `StateHost` foundation everyone depends on)
2. Phase 2 (independent; lands quickly)
3. Phase 5 (independent; lands quickly)
4. Phase 4 (vault.ts split — biggest visible change)
5. Phase 6 (status indicator — completes the parity gap)
6. Phase 3 (setup wizard — biggest single phase, save for last so all the supporting infra is in place)

**For parallel execution** (subagent-driven-development), Phases 1, 2, 5 can ship in parallel; Phase 4 ships after Phase 1; Phase 6 ships after Phase 4; Phase 3 ships after Phase 1. Maximum parallelism is 3 streams.

## Cross-plan coordination

This plan ("Plan C") explicitly does NOT touch:
- Plan A (security/docs polish) — already shipped (commits `89090a8`, `0c9387f`, `229e483`).
- Plan B (CLI restructure) — already shipped (Plan B Cycles 1+2 + the read-side cleanup `d717f0d`).

`extension/src/wasm.d.ts` is shared with future plans that may add parser exports. This plan does not touch that file (verify with `git diff main -- extension/src/wasm.d.ts` empty at completion).
