Files
relicario/docs/superpowers/plans/2026-04-27-vault-tab-session-timeout.md
adlee-was-taken bd13854f59 docs: vault tab + session timeout implementation plan
7 tasks: session timer, popup navigation, vault scaffold,
shared state host, device settings, router fix, manual testing.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-27 02:19:31 -04:00

42 KiB

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:

| { type: 'get_session_config' }
| { type: 'update_session_config'; config: SessionTimeoutConfig }

Add the config type at the top of the file:

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:

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:

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:

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:

resetTimer();

Add the expiration handler at module scope (after state is defined):

onExpired(() => {
  clearCurrent();
  state.manifest = null;
  chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
});

Load saved config from chrome.storage.local during init:

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:

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

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:

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:

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:

<button class="btn" id="open-vault-btn">open vault</button>

Wire the listener:

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

<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)"></button>

Wire the listener:

document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());

In handleListKeydown, add Shift+F handler before the existing key checks:

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:

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

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

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

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:

vault: './src/vault/vault.ts',

In the CopyPlugin patterns array, add:

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

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

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:

ls dist/vault.html dist/vault.css dist/vault.js
  • Step 7: Commit
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
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:

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

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:

import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup';

with:

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:

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:

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:

<button class="btn" id="vault-new-btn" style="font-size:11px;">+ new</button>

Wire it:

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

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

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:

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

const vaultUrl = chrome.runtime.getURL('vault.html');

Update isPopup to include vault:

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