Files
relicario/docs/superpowers/plans/2026-05-30-extension-restructure.md
adlee-was-taken 4a1c553f9d docs(plan): extension restructure — 6-phase implementation plan
24 tasks across 6 phases derived from the 2026-05-04 extension restructure
spec. Per-task bite-sized steps (TDD where new behavior, verify-existing-
tests where pure relocation) with explicit file/line citations and full
code snippets.

Phase 1 (StateHost typing, S-M, blocks 3+4):     5 tasks
Phase 2 (storage.ts + itemToManifestEntry, S):   3 tasks
Phase 3 (setup wizard SW migration + step registry, L): 7 tasks
Phase 4 (vault.ts split into 5 modules + vault_locked lift, M): 7 tasks
Phase 5 (P2 cluster: timer/gitHost/teardown/allSettled/debounce, M): 5 tasks
Phase 6 (get_vault_status + sidebar status indicator, S-M):     3 tasks
Task 7.1 (final verification sweep against spec Done criteria).

Recommended sequence: 1 → 2 → 5 → 4 → 6 → 3 (independents first, then
the typed-StateHost-dependent phases, then Phase 3 last because it's the
biggest single phase and benefits from all the supporting infra in
place). Max subagent parallelism: 3 streams.

Cross-plan: explicit out-of-scope notes for Plan A (security/docs polish,
already shipped) and Plan B (CLI restructure, already shipped). The
wasm.d.ts file is not touched by this plan (verify empty diff at done).

STATUS + ROADMAP updated to point at the plan.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 21:36:55 -04:00

2661 lines
85 KiB
Markdown

# 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).