# Vault Tab UI + Session Timeout — 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:** Add a full "desktop-like" vault UI in a browser tab with sidebar+detail layout, plus configurable session timeout shared between popup and vault tab. **Architecture:** New `vault.html` entry point with its own state management and hash-based routing, rendered as a sidebar+pane layout. Session timeout lives in the service worker via a new `session-timer.ts` module that resets on every message and broadcasts `session_expired` to all views. Both popup and vault tab import the same form/detail component renderers — vault passes its right pane element, popup passes its app element. **Tech Stack:** TypeScript, webpack (new entry point), Chrome MV3 APIs (chrome.commands, chrome.runtime.sendMessage, chrome.storage.local) --- ### Task 1: Session Timer — Service Worker **Files:** - Create: `src/service-worker/session-timer.ts` - Modify: `src/service-worker/index.ts` - Modify: `src/service-worker/session.ts` - Modify: `src/shared/messages.ts` - Test: `src/service-worker/__tests__/session-timer.test.ts` - [ ] **Step 1: Define session config types in shared/messages.ts** Add the new message types and session config type. In `src/shared/messages.ts`, add to the `PopupMessage` union: ```typescript | { type: 'get_session_config' } | { type: 'update_session_config'; config: SessionTimeoutConfig } ``` Add the config type at the top of the file: ```typescript export type SessionTimeoutConfig = | { mode: 'inactivity'; minutes: number } | { mode: 'every_time' }; ``` Add both new types to the `POPUP_ONLY_TYPES` set. - [ ] **Step 2: Write the failing test for session-timer.ts** Create `src/service-worker/__tests__/session-timer.test.ts`: ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { resetTimer, stopTimer, getConfig, setConfig, onExpired } from '../session-timer'; describe('session-timer', () => { beforeEach(() => { vi.useFakeTimers(); stopTimer(); // Reset to default setConfig({ mode: 'inactivity', minutes: 15 }); }); afterEach(() => { vi.useRealTimers(); stopTimer(); }); it('fires callback after inactivity timeout', () => { const cb = vi.fn(); onExpired(cb); resetTimer(); vi.advanceTimersByTime(15 * 60 * 1000); expect(cb).toHaveBeenCalledOnce(); }); it('resets the timer on each call to resetTimer', () => { const cb = vi.fn(); onExpired(cb); resetTimer(); vi.advanceTimersByTime(14 * 60 * 1000); resetTimer(); // reset before it fires vi.advanceTimersByTime(14 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); vi.advanceTimersByTime(1 * 60 * 1000); expect(cb).toHaveBeenCalledOnce(); }); it('does not fire when mode is every_time', () => { const cb = vi.fn(); onExpired(cb); setConfig({ mode: 'every_time' }); resetTimer(); vi.advanceTimersByTime(60 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); }); it('respects updated minutes', () => { const cb = vi.fn(); onExpired(cb); setConfig({ mode: 'inactivity', minutes: 5 }); resetTimer(); vi.advanceTimersByTime(5 * 60 * 1000); expect(cb).toHaveBeenCalledOnce(); }); it('getConfig returns current config', () => { setConfig({ mode: 'inactivity', minutes: 30 }); expect(getConfig()).toEqual({ mode: 'inactivity', minutes: 30 }); }); it('stopTimer prevents firing', () => { const cb = vi.fn(); onExpired(cb); resetTimer(); stopTimer(); vi.advanceTimersByTime(60 * 60 * 1000); expect(cb).not.toHaveBeenCalled(); }); }); ``` - [ ] **Step 3: Run test to verify it fails** Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer` Expected: FAIL — module not found - [ ] **Step 4: Implement session-timer.ts** Create `src/service-worker/session-timer.ts`: ```typescript import type { SessionTimeoutConfig } from '../shared/messages'; let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 }; let timerId: ReturnType | null = null; let expiredCallback: (() => void) | null = null; export function onExpired(cb: () => void): void { expiredCallback = cb; } export function getConfig(): SessionTimeoutConfig { return config; } export function setConfig(c: SessionTimeoutConfig): void { config = c; stopTimer(); } export function resetTimer(): void { stopTimer(); if (config.mode === 'every_time') return; timerId = setTimeout(() => { timerId = null; expiredCallback?.(); }, config.minutes * 60 * 1000); } export function stopTimer(): void { if (timerId !== null) { clearTimeout(timerId); timerId = null; } } ``` - [ ] **Step 5: Run test to verify it passes** Run: `cd /home/alee/Sources/relicario/extension && bun test session-timer` Expected: all 6 tests PASS - [ ] **Step 6: Wire timer into service worker index.ts** In `src/service-worker/index.ts`, import the timer and wire it up: ```typescript import { resetTimer, onExpired, setConfig, getConfig } from './session-timer'; import { clearCurrent } from './session'; ``` In the message listener, after `state.wasm` is initialized and before `route()`, add timer reset: ```typescript resetTimer(); ``` Add the expiration handler at module scope (after `state` is defined): ```typescript onExpired(() => { clearCurrent(); state.manifest = null; chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {}); }); ``` Load saved config from `chrome.storage.local` during init: ```typescript chrome.storage.local.get('session_timeout').then((result) => { if (result.session_timeout) setConfig(result.session_timeout); }); ``` - [ ] **Step 7: Add session config handlers to router/popup-only.ts** In `src/service-worker/router/popup-only.ts`, add two new cases: ```typescript case 'get_session_config': { const { getConfig } = await import('../session-timer'); return { ok: true, data: { config: getConfig() } }; } case 'update_session_config': { const { setConfig, resetTimer } = await import('../session-timer'); const config = (msg as { config: SessionTimeoutConfig }).config; setConfig(config); resetTimer(); await chrome.storage.local.set({ session_timeout: config }); return { ok: true }; } ``` Import `SessionTimeoutConfig` from `../../shared/messages`. - [ ] **Step 8: Build and run tests** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5 && bun test session-timer` Expected: Build succeeds, tests pass. - [ ] **Step 9: Commit** ```bash git add src/service-worker/session-timer.ts src/service-worker/__tests__/session-timer.test.ts src/service-worker/index.ts src/service-worker/router/popup-only.ts src/shared/messages.ts git commit -m "feat(ext/sw): add session inactivity timeout with configurable timer" ``` --- ### Task 2: Popup Listens for Session Expiry + "Open Vault" Links **Files:** - Modify: `src/popup/popup.ts` - Modify: `src/popup/components/unlock.ts` - Modify: `src/popup/components/item-list.ts` - [ ] **Step 1: Add session_expired listener to popup.ts** In `src/popup/popup.ts`, in the `DOMContentLoaded` handler (after `sendMessage({ type: 'is_unlocked' })`), add a listener: ```typescript chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'session_expired') { currentState.view = 'locked'; currentState.error = null; currentState.selectedItem = null; currentState.selectedId = null; render(); } }); ``` - [ ] **Step 2: Add openVaultTab helper to popup.ts** Export a helper function in `src/popup/popup.ts`: ```typescript export function openVaultTab(hash?: string): void { const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : ''); chrome.tabs.create({ url }); } ``` - [ ] **Step 3: Add "Open vault" link to unlock screen** In `src/popup/components/unlock.ts`, find the settings button at the bottom and add an "open vault" button next to it: ```html ``` Wire the listener: ```typescript document.getElementById('open-vault-btn')?.addEventListener('click', () => openVaultTab()); ``` Import `openVaultTab` from `../popup`. - [ ] **Step 4: Add "Open vault" button and Shift+F to item list toolbar** In `src/popup/components/item-list.ts`, add a small button to the toolbar row (after lock button): ```html ``` Wire the listener: ```typescript document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab()); ``` In `handleListKeydown`, add Shift+F handler before the existing key checks: ```typescript if (e.key === 'F' && e.shiftKey) { e.preventDefault(); openVaultTab(); return; } ``` Import `openVaultTab` from `../popup`. - [ ] **Step 5: Update popOutToTab to use vault.html** In `src/popup/popup.ts`, change `popOutToTab()` to open `vault.html` with a hash: ```typescript export function popOutToTab(): void { const state = getState(); if (state.newType) { openVaultTab(`add/${state.newType}`); } else if (state.selectedId) { openVaultTab(`${state.view}/${state.selectedId}`); } else { openVaultTab(); } window.close(); } ``` - [ ] **Step 6: Build and smoke test** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` Expected: Build succeeds. - [ ] **Step 7: Commit** ```bash git add src/popup/popup.ts src/popup/components/unlock.ts src/popup/components/item-list.ts git commit -m "feat(ext/popup): session expiry listener, open-vault links, Shift+F shortcut" ``` --- ### Task 3: Webpack + Manifest + vault.html Scaffold **Files:** - Create: `src/vault/vault.html` - Create: `src/vault/vault.css` - Create: `src/vault/vault.ts` - Modify: `webpack.config.js` - Modify: `manifest.json` - [ ] **Step 1: Create vault.html** Create `src/vault/vault.html`: ```html relicario — vault
``` - [ ] **Step 2: Create vault.css with shared base styles and layout** Create `src/vault/vault.css`. Copy the base styles (reset, colors, typography, scrollbar, buttons, inputs, form elements) from `src/popup/styles.css` — everything up through the component styles but NOT the popup-specific layout (body width/max-height). Then add the vault layout: ```css /* ---- Vault layout ---- */ body { background: #0d1117; color: #c9d1d9; font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace; font-size: 13px; line-height: 1.5; margin: 0; height: 100vh; overflow: hidden; } #vault-app { display: flex; height: 100vh; } .vault-sidebar { width: 260px; min-width: 260px; border-right: 1px solid #21262d; display: flex; flex-direction: column; height: 100vh; overflow: hidden; } .vault-sidebar__header { padding: 12px 16px; border-bottom: 1px solid #21262d; display: flex; align-items: center; gap: 8px; } .vault-sidebar__search { padding: 8px 12px; border-bottom: 1px solid #21262d; } .vault-sidebar__search input { width: 100%; background: #161b22; border: 1px solid #30363d; border-radius: 4px; color: #c9d1d9; padding: 6px 10px; font-size: 12px; font-family: inherit; } .vault-sidebar__list { flex: 1; overflow-y: auto; } .vault-sidebar__nav { border-top: 1px solid #21262d; padding: 8px 0; } .vault-sidebar__nav-item { display: flex; align-items: center; gap: 8px; padding: 6px 16px; color: #8b949e; font-size: 12px; cursor: pointer; border: none; background: none; width: 100%; text-align: left; font-family: inherit; } .vault-sidebar__nav-item:hover { color: #c9d1d9; background: #161b22; } .vault-pane { flex: 1; overflow-y: auto; padding: 24px 32px; } .vault-pane--empty { display: flex; align-items: center; justify-content: center; color: #484f58; font-size: 14px; } /* Sidebar item rows */ .vault-entry { display: flex; align-items: center; gap: 8px; padding: 8px 16px; cursor: pointer; border-left: 2px solid transparent; font-size: 12px; } .vault-entry:hover { background: #161b22; } .vault-entry.selected { background: #161b22; border-left-color: #d2ab43; } .vault-entry__title { color: #c9d1d9; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .vault-entry__meta { color: #484f58; font-size: 11px; } /* Type group headers in sidebar */ .vault-group-header { padding: 12px 16px 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #484f58; } ``` Include the shared component styles the pane needs (form groups, buttons, field rows, detail view, generator panel, attachments, etc.) — copy these from `src/popup/styles.css` so the pane renders correctly. Omit the popup-specific `.search-bar`, `.keyhints`, `.entry-list`, `.entry-row` classes (the vault sidebar uses its own classes above). - [ ] **Step 3: Create vault.ts scaffold with state management and hash routing** Create `src/vault/vault.ts`: ```typescript import type { Item, ItemId, ItemType, ManifestEntry, VaultSettings } from '../shared/types'; import type { Request, Response } from '../shared/messages'; // --- Messaging --- function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { resolve(response); }); }); } function escapeHtml(s: string): string { return s .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // --- State --- type VaultView = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history'; interface VaultState { view: VaultView; entries: Array<[ItemId, ManifestEntry]>; selectedId: ItemId | null; selectedItem: Item | null; searchQuery: string; newType: ItemType | null; error: string | null; loading: boolean; vaultSettings: VaultSettings | null; historyItemId: ItemId | null; } const state: VaultState = { view: 'locked', entries: [], selectedId: null, selectedItem: null, searchQuery: '', newType: null, error: null, loading: false, vaultSettings: null, historyItemId: null, }; // --- Hash routing --- function parseHash(): { view: string; param?: string } { const hash = window.location.hash.slice(1); // remove '#' if (!hash) return { view: 'list' }; const [view, param] = hash.split('/'); return { view, param }; } function setHash(view: string, param?: string): void { window.location.hash = param ? `${view}/${param}` : view; } // --- Render --- function render(): void { const app = document.getElementById('vault-app'); if (!app) return; if (state.view === 'locked') { renderLockScreen(app); return; } renderShell(app); } function renderLockScreen(app: HTMLElement): void { app.innerHTML = `
relicario
two-factor vault
${state.error ? `
${escapeHtml(state.error)}
` : ''}
`; const input = document.getElementById('passphrase') as HTMLInputElement; input?.focus(); const doUnlock = async (): Promise => { const passphrase = input.value; if (!passphrase) return; state.loading = true; state.error = null; const resp = await sendMessage({ type: 'unlock', passphrase }); state.loading = false; if (resp.ok) { await loadManifest(); } else { state.error = resp.error ?? 'Unlock failed'; render(); } }; document.getElementById('unlock-btn')?.addEventListener('click', doUnlock); input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') doUnlock(); }); } function renderShell(app: HTMLElement): void { // Preserve sidebar if already rendered, only update pane if (!app.querySelector('.vault-sidebar')) { app.innerHTML = `
relicario
select an item
`; wireSidebar(); } renderSidebarList(); renderPane(); } function wireSidebar(): void { const searchInput = document.getElementById('vault-search') as HTMLInputElement | null; searchInput?.addEventListener('input', () => { state.searchQuery = searchInput.value; renderSidebarList(); }); document.getElementById('vault-lock-btn')?.addEventListener('click', async () => { await sendMessage({ type: 'lock' }); state.view = 'locked'; state.selectedItem = null; state.selectedId = null; state.entries = []; render(); }); document.querySelectorAll('[data-nav]').forEach((btn) => { btn.addEventListener('click', () => { const nav = btn.dataset.nav as VaultView; state.view = nav; state.selectedId = null; state.selectedItem = null; setHash(nav); renderPane(); }); }); document.addEventListener('keydown', (e) => { if (e.key === '/' && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { e.preventDefault(); searchInput?.focus(); } }); } function renderSidebarList(): void { const listEl = document.getElementById('vault-item-list'); if (!listEl) return; let filtered = state.entries; if (state.searchQuery) { const q = state.searchQuery.toLowerCase(); filtered = filtered.filter(([, e]) => e.title.toLowerCase().includes(q) || (e.icon_hint ?? '').toLowerCase().includes(q) || e.tags.some(t => t.toLowerCase().includes(q)) ); } // Group by type const groups = new Map>(); for (const entry of filtered) { const type = entry[1].type; if (!groups.has(type)) groups.set(type, []); groups.get(type)!.push(entry); } const typeOrder: string[] = ['login', 'secure_note', 'identity', 'card', 'key', 'totp', 'document']; const typeLabel: Record = { login: 'logins', secure_note: 'notes', identity: 'identities', card: 'cards', key: 'keys', totp: 'totp', document: 'documents', }; const typeIcon: Record = { login: '🔑', secure_note: '📝', identity: '🪪', card: '💳', key: '🗝', totp: '⏱', document: '📄', }; let html = ''; for (const type of typeOrder) { const items = groups.get(type); if (!items || items.length === 0) continue; html += `
${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}
`; for (const [id, e] of items) { const sel = id === state.selectedId ? ' selected' : ''; html += `
${escapeHtml(e.title)}
`; } } if (!html) html = '
no items
'; listEl.innerHTML = html; listEl.querySelectorAll('.vault-entry').forEach((el) => { el.addEventListener('click', async () => { const id = el.dataset.id!; state.selectedId = id; state.loading = true; renderSidebarList(); // update selection highlight const resp = await sendMessage({ type: 'get_item', id }); state.loading = false; if (resp.ok) { const data = resp.data as { item: Item }; state.selectedItem = data.item; state.view = 'detail'; setHash('item', id); renderPane(); } }); }); } function renderPane(): void { const pane = document.getElementById('vault-pane'); if (!pane) return; pane.className = 'vault-pane'; switch (state.view) { case 'detail': if (state.selectedItem) { renderPaneDetail(pane, state.selectedItem); } else { pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; } break; case 'add': renderPaneAdd(pane); break; case 'edit': renderPaneEdit(pane); break; case 'trash': renderPaneSection(pane, 'trash'); break; case 'devices': renderPaneSection(pane, 'devices'); break; case 'settings': renderPaneSection(pane, 'settings'); break; case 'settings-vault': renderPaneSection(pane, 'settings-vault'); break; case 'field-history': renderPaneSection(pane, 'field-history'); break; default: pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; } } // --- Pane renderers --- // These import the existing popup component renderers and pass the pane element. // The popup renderers write to whatever element they receive via `app`. async function renderPaneDetail(pane: HTMLElement, item: Item): Promise { // Dynamic import to reuse popup detail renderers const { renderItemDetail } = await import('../popup/components/item-detail'); renderItemDetail(pane); } function renderPaneAdd(pane: HTMLElement): void { import('../popup/components/item-form').then(({ renderItemForm }) => { renderItemForm(pane, 'add'); }); } function renderPaneEdit(pane: HTMLElement): void { import('../popup/components/item-form').then(({ renderItemForm }) => { renderItemForm(pane, 'edit'); }); } async function renderPaneSection(pane: HTMLElement, section: string): Promise { switch (section) { case 'trash': { const { renderTrash } = await import('../popup/components/trash'); renderTrash(pane); break; } case 'devices': { const { renderDevices } = await import('../popup/components/devices'); renderDevices(pane); break; } case 'settings': { const { renderSettings } = await import('../popup/components/settings'); renderSettings(pane); break; } case 'settings-vault': { const { renderVaultSettings } = await import('../popup/components/settings-vault'); renderVaultSettings(pane); break; } case 'field-history': { const { renderFieldHistory } = await import('../popup/components/field-history'); renderFieldHistory(pane); break; } } } // --- Init --- async function loadManifest(): Promise { const listResp = await sendMessage({ type: 'list_items' }); if (listResp.ok) { const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; state.entries = data.items; } const vsResp = await sendMessage({ type: 'get_vault_settings' }); if (vsResp.ok) { state.vaultSettings = (vsResp.data as { settings: VaultSettings }).settings; } state.view = 'list'; render(); // Handle deep link from hash const { view, param } = parseHash(); if (view === 'item' && param) { const resp = await sendMessage({ type: 'get_item', id: param }); if (resp.ok) { state.selectedId = param; state.selectedItem = (resp.data as { item: Item }).item; state.view = 'detail'; renderSidebarList(); renderPane(); } } else if (view === 'add' && param) { state.newType = param as ItemType; state.view = 'add'; renderPane(); } else if (view === 'trash' || view === 'devices' || view === 'settings') { state.view = view as VaultView; renderPane(); } } document.addEventListener('DOMContentLoaded', async () => { const resp = await sendMessage({ type: 'is_unlocked' }); if (resp.ok && (resp.data as { unlocked: boolean }).unlocked) { await loadManifest(); } else { state.view = 'locked'; render(); } }); // Listen for session expiry chrome.runtime.onMessage.addListener((msg) => { if (msg.type === 'session_expired') { state.view = 'locked'; state.error = null; state.selectedItem = null; state.selectedId = null; state.entries = []; render(); } }); // Hash change navigation window.addEventListener('hashchange', () => { const { view, param } = parseHash(); if (view === 'trash' || view === 'devices' || view === 'settings') { state.view = view as VaultView; state.selectedId = null; state.selectedItem = null; renderPane(); } }); ``` - [ ] **Step 4: Add vault entry point to webpack.config.js** In `webpack.config.js`, add to the `entry` object: ```javascript vault: './src/vault/vault.ts', ``` In the CopyPlugin patterns array, add: ```javascript { from: 'src/vault/vault.html', to: 'vault.html' }, { from: 'src/vault/vault.css', to: 'vault.css' }, ``` - [ ] **Step 5: Update manifest.json with commands and vault.html access** In `manifest.json`, add a `commands` section: ```json "commands": { "open-vault": { "description": "Open relicario vault" } } ``` Note: no `suggested_key` — the user configures it in `chrome://extensions/shortcuts`. Add the commands listener in `src/service-worker/index.ts`: ```typescript chrome.commands.onCommand.addListener((command) => { if (command === 'open-vault') { chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') }); } }); ``` - [ ] **Step 6: Build and verify** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10` Expected: Build succeeds with vault.js in output, vault.html and vault.css copied to dist/. Verify files exist: ```bash ls dist/vault.html dist/vault.css dist/vault.js ``` - [ ] **Step 7: Commit** ```bash git add src/vault/ webpack.config.js manifest.json src/service-worker/index.ts git commit -m "feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing" ``` --- ### Task 4: Resolve Shared State — Vault Tab Uses Popup's State Functions **Files:** - Modify: `src/popup/popup.ts` - Modify: `src/vault/vault.ts` - Modify: `src/popup/components/item-detail.ts` (if it calls navigate/setState) - Modify: any popup component that calls `navigate`, `setState`, `getState`, `sendMessage` The popup components (item-detail, item-form, trash, devices, settings, etc.) all import from `../popup` (i.e., `src/popup/popup.ts`) for `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`. When the vault tab dynamically imports these components, those imports resolve to the popup's module — but in the vault's webpack bundle, `popup.ts` won't be initialized (no `DOMContentLoaded`, wrong state). The fix: extract the shared functions into a new `src/shared/state.ts` that both popup and vault initialize with their own state/render callbacks. - [ ] **Step 1: Create src/shared/state.ts** ```typescript import type { Request, Response } from './messages'; export interface StateHost { getState(): any; setState(partial: any): void; navigate(view: string, extras?: any): void; sendMessage(request: Request): Promise; escapeHtml(s: string): string; popOutToTab(): void; isInTab(): boolean; openVaultTab(hash?: string): void; } let host: StateHost | null = null; export function registerHost(h: StateHost): void { host = h; } export function getState(): any { if (!host) throw new Error('No state host registered'); return host.getState(); } export function setState(partial: any): void { if (!host) throw new Error('No state host registered'); host.setState(partial); } export function navigate(view: string, extras?: any): void { if (!host) throw new Error('No state host registered'); host.navigate(view, extras); } export function sendMessage(request: Request): Promise { if (!host) throw new Error('No state host registered'); return host.sendMessage(request); } export function escapeHtml(s: string): string { if (!host) throw new Error('No state host registered'); return host.escapeHtml(s); } export function popOutToTab(): void { if (!host) throw new Error('No state host registered'); host.popOutToTab(); } export function isInTab(): boolean { if (!host) return false; return host.isInTab(); } export function openVaultTab(hash?: string): void { if (!host) throw new Error('No state host registered'); host.openVaultTab(hash); } ``` - [ ] **Step 2: Update popup.ts to register as host** In `src/popup/popup.ts`, at the end of the existing exports section (before the `DOMContentLoaded` listener), call `registerHost`: ```typescript import { registerHost } from '../shared/state'; registerHost({ getState: () => currentState, setState, navigate, sendMessage, escapeHtml, popOutToTab, isInTab, openVaultTab, }); ``` Keep the existing exports — the popup's own code can still import directly from `./popup`. The state host is for cross-bundle component access. - [ ] **Step 3: Update vault.ts to register as host** In `src/vault/vault.ts`, register its own state host before calling `render()`: ```typescript import { registerHost } from '../shared/state'; registerHost({ getState: () => state, setState: (partial: Partial) => { Object.assign(state, partial); render(); }, navigate: (view: string, extras?: Partial) => { Object.assign(state, { view, error: null, loading: false, ...extras }); if (view === 'list') { state.selectedId = null; state.selectedItem = null; } setHash(view); render(); }, sendMessage, escapeHtml, popOutToTab: () => {}, // no-op, already in tab isInTab: () => true, openVaultTab: () => {}, // no-op, already in vault }); ``` - [ ] **Step 4: Update all popup components to import from shared/state** In every file under `src/popup/components/` that imports from `../popup` or `../../popup`, change the import to use `../../shared/state` (or `../shared/state` depending on depth). Files to update (all imports of `navigate`, `setState`, `getState`, `sendMessage`, `escapeHtml`, `popOutToTab`, `isInTab`, `openVaultTab`): - `src/popup/components/item-list.ts` - `src/popup/components/item-detail.ts` - `src/popup/components/item-form.ts` - `src/popup/components/unlock.ts` - `src/popup/components/settings.ts` - `src/popup/components/settings-vault.ts` - `src/popup/components/trash.ts` - `src/popup/components/devices.ts` - `src/popup/components/field-history.ts` - `src/popup/components/fields.ts` - `src/popup/components/generator-panel.ts` - `src/popup/components/attachments-disclosure.ts` - `src/popup/components/types/login.ts` - `src/popup/components/types/secure-note.ts` - `src/popup/components/types/identity.ts` - `src/popup/components/types/card.ts` - `src/popup/components/types/key.ts` - `src/popup/components/types/totp.ts` - `src/popup/components/types/document.ts` For each file, replace: ```typescript import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup'; ``` with: ```typescript import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../shared/state'; ``` Adjust relative path depth accordingly (components in `types/` need `../../../shared/state`; components directly in `components/` need `../../shared/state`). Note: `popup.ts` itself should NOT change its own internal function definitions — it still defines `setState`, `navigate`, etc. locally. It just also calls `registerHost` to expose them to the shared state module. - [ ] **Step 4b: Update test mocks to match new import paths** Test files under `src/popup/components/__tests__/` and `src/popup/components/types/__tests__/` mock `../../popup` or `../../../popup`. Update all `vi.mock('../../popup', ...)` calls to `vi.mock('../../shared/state', ...)` (and similarly for `../../../popup` → `../../../shared/state`). The mock shape stays the same — just the path changes. Test files to update: - `src/popup/components/__tests__/attachments-disclosure.test.ts` - `src/popup/components/__tests__/devices.test.ts` - `src/popup/components/__tests__/field-history.test.ts` - `src/popup/components/__tests__/fields.test.ts` - `src/popup/components/__tests__/generator-panel.test.ts` - `src/popup/components/__tests__/sections-editor.test.ts` - `src/popup/components/__tests__/sections-render.test.ts` - `src/popup/components/__tests__/settings-vault.test.ts` - `src/popup/components/__tests__/trash.test.ts` - `src/popup/components/types/__tests__/card.save.test.ts` - `src/popup/components/types/__tests__/document.save.test.ts` - `src/popup/components/types/__tests__/identity.save.test.ts` - `src/popup/components/types/__tests__/key.save.test.ts` - `src/popup/components/types/__tests__/sections-save.test.ts` - `src/popup/components/types/__tests__/secure-note.save.test.ts` - `src/popup/components/types/__tests__/totp.save.test.ts` - [ ] **Step 5: Update vault.ts pane renderers to drop dynamic import wrappers** Since the components now import from `shared/state` (which vault.ts has registered as host), the vault can import them directly. Simplify the pane render functions in `vault.ts`: ```typescript import { renderItemDetail } from '../popup/components/item-detail'; import { renderItemForm } from '../popup/components/item-form'; import { renderTrash } from '../popup/components/trash'; import { renderDevices } from '../popup/components/devices'; import { renderSettings } from '../popup/components/settings'; import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault'; import { renderFieldHistory } from '../popup/components/field-history'; ``` Replace the async pane renderers with direct calls: ```typescript function renderPane(): void { const pane = document.getElementById('vault-pane'); if (!pane) return; pane.className = 'vault-pane'; switch (state.view) { case 'detail': if (state.selectedItem) { renderItemDetail(pane); } else { pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; } break; case 'add': renderItemForm(pane, 'add'); break; case 'edit': renderItemForm(pane, 'edit'); break; case 'trash': renderTrash(pane); break; case 'devices': renderDevices(pane); break; case 'settings': renderSettings(pane); break; case 'settings-vault': renderVaultSettingsView(pane); break; case 'field-history': renderFieldHistory(pane); break; default: pane.className = 'vault-pane vault-pane--empty'; pane.innerHTML = 'select an item'; } } ``` Remove the old `renderPaneDetail`, `renderPaneAdd`, `renderPaneEdit`, `renderPaneSection` functions. - [ ] **Step 6: Add "+ new" button to sidebar header** In the sidebar header of `vault.ts`, add a new button: ```html ``` Wire it: ```typescript document.getElementById('vault-new-btn')?.addEventListener('click', () => { state.newType = null; state.view = 'add'; setHash('add'); renderPane(); }); ``` - [ ] **Step 7: Build and test** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10` Expected: Build succeeds. Both popup.js and vault.js compile. Run: `bun test 2>&1 | tail -20` Expected: Existing tests still pass (components import from shared/state, which delegates to registered host — tests mock the functions they need). - [ ] **Step 8: Commit** ```bash git add src/shared/state.ts src/vault/vault.ts src/popup/popup.ts src/popup/components/ git commit -m "feat(ext): shared state host so vault tab reuses popup components" ``` --- ### Task 5: Device Settings — Session Timeout Config UI **Files:** - Modify: `src/popup/components/settings.ts` - Modify: `src/vault/vault.ts` (settings nav already wired) - [ ] **Step 1: Read current settings.ts to understand structure** Read `src/popup/components/settings.ts` to see how the existing settings view is structured and where to add device settings. - [ ] **Step 2: Add session timeout UI to settings view** In `src/popup/components/settings.ts`, in the `renderSettings` function, add a "device settings" section at the top (before vault settings link): ```html

device

``` Fetch the current config on render: ```typescript const configResp = await sendMessage({ type: 'get_session_config' }); const config = configResp.ok ? (configResp.data as { config: SessionTimeoutConfig }).config : { mode: 'inactivity' as const, minutes: 15 }; ``` Wire the change handlers: ```typescript document.getElementById('timeout-mode')?.addEventListener('change', async (e) => { const mode = (e.target as HTMLSelectElement).value; const minutesSelect = document.getElementById('timeout-minutes') as HTMLSelectElement; if (mode === 'every_time') { minutesSelect.disabled = true; await sendMessage({ type: 'update_session_config', config: { mode: 'every_time' } }); } else { minutesSelect.disabled = false; const minutes = parseInt(minutesSelect.value, 10); await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } }); } }); document.getElementById('timeout-minutes')?.addEventListener('change', async (e) => { const minutes = parseInt((e.target as HTMLSelectElement).value, 10); await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } }); }); ``` Import `SessionTimeoutConfig` from `../../shared/messages`. Note: `renderSettings` needs to become `async` for the `sendMessage` call. Update the function signature and the caller in popup.ts render switch. - [ ] **Step 3: Build and test** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` Expected: Build succeeds. - [ ] **Step 4: Commit** ```bash git add src/popup/components/settings.ts src/popup/popup.ts git commit -m "feat(ext/settings): add session timeout config to device settings" ``` --- ### Task 6: Router — Allow vault.html as Trusted Sender **Files:** - Modify: `src/service-worker/router/index.ts` - [ ] **Step 1: Update router to recognize vault.html** In `src/service-worker/router/index.ts`, add vault URL detection: ```typescript const vaultUrl = chrome.runtime.getURL('vault.html'); ``` Update `isPopup` to include vault: ```typescript const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl); ``` - [ ] **Step 2: Build** Run: `cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5` Expected: Build succeeds. - [ ] **Step 3: Commit** ```bash git add src/service-worker/router/index.ts git commit -m "fix(ext/router): allow vault.html as trusted sender for popup-only messages" ``` --- ### Task 7: Manual Browser Testing - [ ] **Step 1: Build the extension** ```bash cd /home/alee/Sources/relicario/extension && bun run build ``` - [ ] **Step 2: Reload extension in Chrome** Go to `chrome://extensions`, click reload on relicario. - [ ] **Step 3: Test popup basics** - Open popup, verify search doesn't auto-focus - Type `/` to focus search, verify text goes forward - Use arrow keys and Enter to open an item - Verify Shift+F opens vault tab - Verify "⤴" button in toolbar opens vault tab - [ ] **Step 4: Test vault tab** - Verify vault tab shows lock screen if locked - Unlock with passphrase - Verify sidebar shows items grouped by type - Click an item — detail appears in right pane - Click "+ new" — type selection appears in pane - Select a type — form appears in pane - Click trash/devices/settings in sidebar nav - Use `/` to focus sidebar search - Verify URL hash updates as you navigate - [ ] **Step 5: Test session timeout** - Go to settings in either popup or vault tab - Set timeout to 5 minutes - Wait 5 minutes (or temporarily set to a short value for testing) - Verify both popup and vault tab show lock screen - [ ] **Step 6: Test popup → vault navigation** - Open popup, view an item, click popout button → verify vault tab opens with that item selected - Open popup, click "+ new", select card → verify vault tab opens with card form - Open popup lock screen, click "open vault" → verify vault tab opens - [ ] **Step 7: Fix any issues found, rebuild, retest, commit**