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>
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.ts—View+PopupStatetypes (moved frompopup/popup.ts).extension/src/shared/__tests__/state.test.ts—StateHostregistration / getState / setState /__resetHostForTestscoverage.extension/src/shared/__tests__/state-vault-locked.test.ts—vault_lockedchannel 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_statushandler 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— typedStateHost, double-registration guard,__resetHostForTests,sendMessagewrapper (Phase 1 lays the wrapper; Phase 4 fills thevault_lockedbody).extension/src/shared/messages.ts— addcreate_vault,attach_vault,get_vault_status(Phase 3 + 6).extension/src/shared/types.ts— re-exportView/PopupStatefrompopup-state.tsfor compatibility (or leave the import paths and skip the re-export — see Task 1.1).extension/src/popup/popup.ts— dropView+PopupStatedefinitions (now inshared/popup-state.ts); import them instead.extension/src/service-worker/router/popup-only.ts— delete duplicatedloadDeviceSettings/loadBlacklist/saveBlacklist/itemToManifestEntry; import fromstorage.ts/vault.ts.extension/src/service-worker/router/content-callable.ts— same deduplication.extension/src/service-worker/vault.ts— gainsitemToManifestEntryexport + 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); clearstate.gitHoston session expiry.extension/src/service-worker/session-timer.ts— defineREAD_ONLY_CONTENT_CALLABLEexclusion set with doc comment.extension/src/setup/setup.ts— delete WASM dynamic-import +loadWasm+ modulewasmbinding +verifiedHandle; convertrenderStepN/attachStepNpairs toSetupStepstep-registry objects; addclearWizardState().extension/src/setup/__tests__/setup.test.ts— assert step-registry shape.extension/src/vault/vault.ts— trim to ~200 LOC of routing + state; deletevault_lockedRPC intercept (lifted toshared/state.ts).extension/src/popup/components/settings.tsandsettings-vault.ts— extractteardownSettingsCommon; both call it.extension/src/popup/components/devices.tsandtrash.ts— switchPromise.alltoPromise.allSettledwith per-slot fallback.extension/src/content/detector.ts— debounce MutationObserverscan().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-wasmRust 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/PopupStatefrompopup/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.tsto find the currentViewandPopupStatedefinitions
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.tswith 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.tsto 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.tswith 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/openVaultTabfromshared/statethat 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).field→state.field(the field exists inPopupState; 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 })(iffoois inPopupState).- If a caller is using a field that genuinely isn't in
PopupState, add it toPopupStateinshared/popup-state.tswith 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/PopupStatefrompopup/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(locateitemToManifestEntry) -
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.tsas 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 itemToManifestEntrydefinitions. - 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 unlock → embed_image_secret → register_device → manifest_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
handleAttachVaultinservice-worker/vault.ts
Same shape as handleCreateVault but the crypto sequence is:
extract_image_secret(referenceImageBytes)→ 32-byte secretunlock(passphrase, image_secret, params)→ SessionHandleregister_device(deviceName)→ keypair- 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
verifiedHandlefromWizardState
Find the WizardState interface in setup.ts and remove the verifiedHandle?: SessionHandle | null field.
- Step 4: Remove the
SessionHandleimport
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 toSetupStepobjects
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
clearWizardStatetest
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_schemeand applies CSS variables). chrome.runtime.onMessagewiring (forsession_expiredand similar SW push events).applyShellViewClass(setsdata-viewattribute 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.drawerOpenreset (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
ensureDrawerClosedForRouteinto 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:
RouterStatedeclaration.- 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_lockedRPC 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.onExpiredcallback
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
teardownSettingsCommontosettings.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.allcalls
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) — noteget_vault_statustype was added tomessages.tsin 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
synchandler 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 --noEmitclean
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.tsStateHostinterface has noanyin public surface —grep -c ": any\|<any>" extension/src/shared/state.tsreturns 0. -
registerHostthrows on second registration — verified by test in Task 1.5. -
__resetHostForTestsexported — 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/*.tsreturns 0. -
itemToManifestEntrydefined 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.tsdoes not importrelicario-wasm—grep -c "relicario-wasm" extension/src/setup/setup.tsreturns 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.tsreturns 3. -
clearWizardStatebound tobeforeunload— grep visually confirms. -
vault.tssplit into 6 modules —ls extension/src/vault/vault-*.tsreturns 5 (vault-shell,vault-sidebar,vault-list,vault-drawer,vault-form-wrapper), plusvault-status.tsfrom Phase 6.wc -l extension/src/vault/vault.tsreturns ≤ ~250. -
vault.tsno longer containsvault_lockedintercept —grep -c "vault_locked" extension/src/vault/vault.tsreturns 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.tsreturns a match. -
Inactivity timer rule inverted — verified by test in Task 5.1.
-
state.gitHost = nullon expiry — verified by grep + test. -
Single
teardownSettingsCommon—grep -rn "function teardownSettingsCommon\|export function teardownSettingsCommon" extension/src/popup/components/returns 1. -
devices.tsandtrash.tsusePromise.allSettled—grep -c "Promise.allSettled" extension/src/popup/components/devices.ts extension/src/popup/components/trash.tsreturns 2. -
detector.tsdebounced —grep "SCAN_DEBOUNCE_MS\|scheduleScan" extension/src/content/detector.tsreturns a match. -
get_vault_statusmessage exists and is rendered — verified by sidebar mount. -
No
wasm.d.tschange —git diff main -- extension/src/wasm.d.tsis 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 bywasm.lockper Plan A policy —grep -B 2 "\.free()" extension/src/and visually confirm each is preceded by awasm.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:
StateHosttyped 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.tssplit into 5 modules +vault_lockedchannel 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:
- Phase 1 (the typed
StateHostfoundation everyone depends on) - Phase 2 (independent; lands quickly)
- Phase 5 (independent; lands quickly)
- Phase 4 (vault.ts split — biggest visible change)
- Phase 6 (status indicator — completes the parity gap)
- 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).