Files
relicario/docs/superpowers/plans/2026-05-30-extension-restructure.md
adlee-was-taken 7c7efa7c43 release: v0.7.0 — extension restructure complete (Plan C Phases 3/4/6)
Completes the extension restructure begun in v0.6.0. Phases 3 (setup
wizard SW migration + step registry), 4 (vault.ts split + vault_locked
lift), and 6 (get_vault_status + sidebar status indicator) all merged to
main (9df2fee, 3b8368d, 397cc78) via three parallel worktree streams.

This commit is the release-prep wrap-up:
- Version bump to v0.7.0 across the three relicario crates + Cargo.lock,
  extension/package.json, and both extension manifests (the manifests had
  lagged at 0.5.0 — corrected here).
- CHANGELOG.md v0.7.0 entry.
- STATUS.md: extension restructure moved to shipped; Phases 3/4/6 landing
  section added.
- ROADMAP.md: v0.7.0 row added; Up-next now command palette.
- extension/ARCHITECTURE.md: all three phases integrated (new vault-*
  modules, setup-steps.ts, get_vault_status protocol + status indicator,
  vault_locked lift, git-host sync cache).
- Plan completion checkboxes ticked.

Task 7.1 verification: done-criteria sweep all green; 423/423 vitest;
build:all clean (only the pre-existing 4MB WASM size warning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:50:17 -04:00

85 KiB

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.tsView + PopupState types (moved from popup/popup.ts).
  • extension/src/shared/__tests__/state.test.tsStateHost registration / getState / setState / __resetHostForTests coverage.
  • extension/src/shared/__tests__/state-vault-locked.test.tsvault_locked channel intercept coverage.
  • extension/src/service-worker/storage.tsloadDeviceSettings / 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.tsrenderFormWrapped + 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

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

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

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
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
cd extension && npx vitest run

Expected: all current tests pass (the type relocation is no-behavior-change).

  • Step 7: Commit
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

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:

// 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
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.

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

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).fieldstate.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
cd extension && npx tsc --noEmit 2>&1 | tail -10

Expected: clean.

  • Step 4: Run vitest
cd extension && npx vitest run

Expected: all tests pass.

  • Step 5: Commit (bundle with Task 1.2 if held)
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

cd extension && grep -rn "from '\.\./popup/popup'\|from '\.\./\.\./popup/popup'" src/ | grep -E "View|PopupState"
  • Step 2: Rewrite each import

For each match, change:

import type { View, PopupState } from '../popup/popup';

To:

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
cd extension && npx tsc --noEmit && npx vitest run

Both should be clean.

  • Step 5: Commit
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:

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
cd extension && npx vitest run src/shared/__tests__/state.test.ts

Expected: 7 passed.

  • Step 3: Commit
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

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:

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

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:

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

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
cd extension && npx vitest run src/service-worker/__tests__/storage.test.ts

Expected: 5 passed.

  • Step 5: Run full suite
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
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

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:

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

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:

'create_vault',
'attach_vault',
'get_vault_status',
  • Step 5: Build to verify
cd extension && npx tsc --noEmit 2>&1 | tail -10

Expected: clean (the new types aren't consumed yet).

  • Step 6: Commit
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

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 unlockembed_image_secretregister_devicemanifest_encrypt then push to git host.

  • Step 2: Check whether vault.test.ts already exists
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):

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

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:

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:

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)
cd extension && npx vitest run src/service-worker/__tests__/vault.test.ts

Expected: both cases pass.

  • Step 9: Commit
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:

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
case 'attach_vault':
  return handleAttachVault(msg, state);
  • Step 5: Run test (expect PASS)

  • Step 6: Commit

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

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

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:

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:

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:

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:

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

/**
 * 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:

window.addEventListener('beforeunload', clearWizardState);
  • Step 3: Call from goto('mode')

In the goto() function:

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

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:

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
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
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
cd extension && npx vitest run
  • Step 6: Commit Tasks 3.4-3.7 as one cohesive commit
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:

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:

// 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
cd extension && npx tsc --noEmit
  • Step 5: Run vitest
cd extension && npx vitest run

Expected: all tests pass (no behavior change).

  • Step 6: Commit
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.).
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:

// 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

cd extension && npx tsc --noEmit && npx vitest run
  • Step 5: Commit
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:

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

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)
cd extension && npx vitest run src/vault/__tests__/drawer-state.test.ts
  • Step 4: Create the file

extension/src/vault/vault-drawer.ts:

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:

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

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:

import { __test__ } from '../vault';

To:

import { __test__ } from '../vault-form-wrapper';
  • Step 4: Build + test

  • Step 5: Commit

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

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

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

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

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:

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:

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

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:

/**
 * 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:

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:

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

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

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:

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:

it('session expiry clears state.gitHost', async () => {
  state.gitHost = makeFakeGitHost();
  // trigger expiry
  expect(state.gitHost).toBeNull();
});
  • Step 4: Run + commit
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

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
/**
 * 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:

export function teardownSettings(): void {
  teardownSettingsCommon();
  // … any settings.ts-specific cleanup
}

In settings-vault.ts:

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

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

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:

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

  • Step 3: Same for trash.ts (line 39-46)
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:

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

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

The current shape:

const observer = new MutationObserver(() => scan());
  • Step 2: Wrap in 200ms debounce

Replace with:

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:

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

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:

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:

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:

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
case 'get_vault_status':
  return handleGetVaultStatus(state);
  • Step 6: Run test (expect PASS)

  • Step 7: Commit

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:

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:

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

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:

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

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

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
cd extension && npx tsc --noEmit 2>&1 | tail -5

Expected: no output (clean).

  • Step 2: Full vitest suite green
cd extension && npx vitest run

Expected: all tests pass.

  • Step 3: Production build clean
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 / saveBlacklistgrep -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-wasmgrep -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 teardownSettingsCommongrep -rn "function teardownSettingsCommon\|export function teardownSettingsCommon" extension/src/popup/components/ returns 1.

  • devices.ts and trash.ts use Promise.allSettledgrep -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:

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.

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
git push origin main

Completion Checklist

  • Phase 1: StateHost typed end-to-end (Tasks 1.1-1.5) — merged 2026-05-30
  • Phase 2: SW helpers consolidated in storage.ts + vault.ts (Tasks 2.1-2.3) — merged 2026-05-30
  • Phase 3: Setup wizard SW-orchestrated + step registry + clearWizardState (Tasks 3.1-3.7) — merged 9df2fee 2026-05-31
  • Phase 4: vault.ts split into 5 modules + vault_locked channel unified (Tasks 4.1-4.7) — merged 3b8368d 2026-06-01
  • Phase 5: Five P2 fixes (Tasks 5.1-5.5) — merged 2026-05-30
  • Phase 6: get_vault_status + sidebar status indicator (Tasks 6.1-6.3) — merged 397cc78 2026-06-01
  • Task 7.1: Final verification + STATUS/ROADMAP update — done-criteria sweep all green; 423/423 vitest; build:all clean; STATUS/ROADMAP/CHANGELOG/ARCHITECTURE updated; versions bumped to v0.7.0

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