# 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<typeof setTimeout> | 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
<button class="btn" id="open-vault-btn">open vault</button>
```

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
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">⤴</button>
```

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
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>relicario — vault</title>
  <link rel="stylesheet" href="vault.css">
</head>
<body>
  <div id="vault-app"></div>
  <script src="vault.js"></script>
</body>
</html>
```

- [ ] **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<Response> {
  return new Promise((resolve) => {
    chrome.runtime.sendMessage(request, (response: Response) => {
      resolve(response);
    });
  });
}

function escapeHtml(s: string): string {
  return s
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

// --- 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 = `
    <div style="display:flex; align-items:center; justify-content:center; height:100vh; width:100%;">
      <div style="text-align:center; width:320px;">
        <img class="brand-logo" src="icons/icon128.png" alt="relicario">
        <div class="brand" style="margin-bottom:4px;">relicario</div>
        <div class="muted" style="margin-bottom:20px;">two-factor vault</div>
        ${state.error ? `<div class="error" style="margin-bottom:12px;">${escapeHtml(state.error)}</div>` : ''}
        <input type="password" id="passphrase" placeholder="passphrase"
               style="width:100%; margin-bottom:12px;">
        <button class="btn btn-primary" id="unlock-btn" style="width:100%;">unlock</button>
      </div>
    </div>
  `;

  const input = document.getElementById('passphrase') as HTMLInputElement;
  input?.focus();

  const doUnlock = async (): Promise<void> => {
    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 = `
      <div class="vault-sidebar">
        <div class="vault-sidebar__header">
          <img src="icons/icon48.png" alt="" style="width:24px; height:24px;">
          <span class="brand" style="font-size:13px;">relicario</span>
          <span style="flex:1;"></span>
          <button class="btn" id="vault-lock-btn" style="font-size:11px;">lock</button>
        </div>
        <div class="vault-sidebar__search">
          <input type="text" id="vault-search" placeholder="/ search...">
        </div>
        <div class="vault-sidebar__list" id="vault-item-list"></div>
        <div class="vault-sidebar__nav">
          <button class="vault-sidebar__nav-item" data-nav="trash">🗑 trash</button>
          <button class="vault-sidebar__nav-item" data-nav="devices">📱 devices</button>
          <button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
        </div>
      </div>
      <div class="vault-pane vault-pane--empty" id="vault-pane">
        select an item
      </div>
    `;

    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<HTMLButtonElement>('[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<string, Array<[ItemId, ManifestEntry]>>();
  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<string, string> = {
    login: 'logins', secure_note: 'notes', identity: 'identities',
    card: 'cards', key: 'keys', totp: 'totp', document: 'documents',
  };
  const typeIcon: Record<string, string> = {
    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 += `<div class="vault-group-header">${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}</div>`;
    for (const [id, e] of items) {
      const sel = id === state.selectedId ? ' selected' : '';
      html += `
        <div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
          <span class="vault-entry__title">${escapeHtml(e.title)}</span>
          <span class="vault-entry__meta">${escapeHtml(e.icon_hint ?? '')}</span>
        </div>`;
    }
  }

  if (!html) html = '<div style="padding:24px 16px; color:#484f58; text-align:center;">no items</div>';

  listEl.innerHTML = html;

  listEl.querySelectorAll<HTMLElement>('.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<void> {
  // 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<void> {
  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<void> {
  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<Response>;
  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<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) 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<VaultState>) => {
    Object.assign(state, partial);
    render();
  },
  navigate: (view: string, extras?: Partial<VaultState>) => {
    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
<button class="btn" id="vault-new-btn" style="font-size:11px;">+ new</button>
```

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
<div class="settings-section">
  <h4 class="settings-heading">device</h4>
  <div class="form-group">
    <label class="label">auto-lock</label>
    <div class="inline-row" style="gap:8px;">
      <select id="timeout-mode" style="flex:1;">
        <option value="inactivity"${config.mode === 'inactivity' ? ' selected' : ''}>after inactivity</option>
        <option value="every_time"${config.mode === 'every_time' ? ' selected' : ''}>every time</option>
      </select>
      <select id="timeout-minutes" style="width:80px;"${config.mode === 'every_time' ? ' disabled' : ''}>
        ${[5, 15, 30, 60].map(m => `<option value="${m}"${config.mode === 'inactivity' && config.minutes === m ? ' selected' : ''}>${m} min</option>`).join('')}
      </select>
    </div>
  </div>
</div>
```

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