From 7d6fd76e86b58e40997a798eb211afd66bb1bf9d Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 3 May 2026 20:26:19 -0400 Subject: [PATCH] feat: v0.5.1 multi-agent coordination plans (PM + DEV-A/B/C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - coordination/v0.5.1-pm-prompt.md — PM coordinates 3 streams, enforces interface contracts (A-B settings signature, B-C security component), owns merge order and pre-tag checklist - coordination/v0.5.1-dev-a-prompt.md — Stream A: fullscreen 3-column layout, sidebar category nav, detail drawer, bottom sheet, popup type- picker polish, per-type glyph icons, empty states, toast system (13 tasks) - coordination/v0.5.1-dev-b-prompt.md — Stream B: settings left-nav redesign (Autofill, Display, Security, Generator, Retention, Backup, Import sections), security component stub (10 tasks) - coordination/v0.5.1-dev-c-prompt.md — Stream C: recovery_qr.rs core, WASM session expansion, CLI subcommand, settings-security.ts three-state component, setup wizard Style C redesign + QR banner (12 tasks) - Archive v0.5.0 coordination files to coordination/archive/ Co-Authored-By: Claude Sonnet 4.6 --- .../{ => archive}/v0.5.0-dev-a-prompt.md | 0 .../{ => archive}/v0.5.0-dev-b-prompt.md | 0 .../{ => archive}/v0.5.0-pm-prompt.md | 0 .../coordination/v0.5.1-dev-a-prompt.md | 1448 +++++++++++++++++ .../coordination/v0.5.1-dev-b-prompt.md | 1074 ++++++++++++ .../coordination/v0.5.1-dev-c-prompt.md | 1353 +++++++++++++++ .../coordination/v0.5.1-pm-prompt.md | 165 ++ 7 files changed, 4040 insertions(+) rename docs/superpowers/coordination/{ => archive}/v0.5.0-dev-a-prompt.md (100%) rename docs/superpowers/coordination/{ => archive}/v0.5.0-dev-b-prompt.md (100%) rename docs/superpowers/coordination/{ => archive}/v0.5.0-pm-prompt.md (100%) create mode 100644 docs/superpowers/coordination/v0.5.1-dev-a-prompt.md create mode 100644 docs/superpowers/coordination/v0.5.1-dev-b-prompt.md create mode 100644 docs/superpowers/coordination/v0.5.1-dev-c-prompt.md create mode 100644 docs/superpowers/coordination/v0.5.1-pm-prompt.md diff --git a/docs/superpowers/coordination/v0.5.0-dev-a-prompt.md b/docs/superpowers/coordination/archive/v0.5.0-dev-a-prompt.md similarity index 100% rename from docs/superpowers/coordination/v0.5.0-dev-a-prompt.md rename to docs/superpowers/coordination/archive/v0.5.0-dev-a-prompt.md diff --git a/docs/superpowers/coordination/v0.5.0-dev-b-prompt.md b/docs/superpowers/coordination/archive/v0.5.0-dev-b-prompt.md similarity index 100% rename from docs/superpowers/coordination/v0.5.0-dev-b-prompt.md rename to docs/superpowers/coordination/archive/v0.5.0-dev-b-prompt.md diff --git a/docs/superpowers/coordination/v0.5.0-pm-prompt.md b/docs/superpowers/coordination/archive/v0.5.0-pm-prompt.md similarity index 100% rename from docs/superpowers/coordination/v0.5.0-pm-prompt.md rename to docs/superpowers/coordination/archive/v0.5.0-pm-prompt.md diff --git a/docs/superpowers/coordination/v0.5.1-dev-a-prompt.md b/docs/superpowers/coordination/v0.5.1-dev-a-prompt.md new file mode 100644 index 0000000..1583508 --- /dev/null +++ b/docs/superpowers/coordination/v0.5.1-dev-a-prompt.md @@ -0,0 +1,1448 @@ +# Dev A Kickoff Prompt — v0.5.1 Stream A (Fullscreen + Popup Layout) + +> **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. + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are a **senior developer** owning Stream A for the Relicario v0.5.1 release. Stream A is the fullscreen vault tab layout overhaul + popup polish: 3-column layout (sidebar category nav, full-width list, slide-in drawer), bottom sheet for new-item type picker, popup type-picker polish, per-type glyph icons, and a shared toast system. + +**Goal:** Replace the current 2-column vault tab (sidebar + single pane) with a responsive 3-column layout. All emoji type icons become Unicode monochrome glyphs. A shared toast module replaces ad-hoc status divs. + +**Architecture:** All changes are in the extension. `vault.ts` is a full layout rewrite. `item-list.ts` and `item-form.ts` get glyph replacements and polish. A new `toast.ts` provides the shared notification API. CSS lives in `vault.css` (fullscreen) and `popup/styles.css` (popup). + +**Tech Stack:** TypeScript, vitest, webpack/bun. + +--- + +## Setup (do this first) + +```bash +cd /home/alee/Sources/relicario +git fetch +git checkout main +git pull +git worktree add ../relicario.v0.5.1-stream-a -b feature/v0.5.1-stream-a-layout +cd ../relicario.v0.5.1-stream-a +pwd # should print /home/alee/Sources/relicario.v0.5.1-stream-a +``` + +**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.5.1-stream-a`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.v0.5.1-stream-a`. + +Today: 2026-05-03. Project rules in `CLAUDE.md` apply. + +## Required reading + +1. `CLAUDE.md` — project rules +2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — spec sections A1–A7 +3. `extension/src/vault/vault.ts` — current implementation (read fully before editing) +4. `extension/src/vault/vault.css` — current styles +5. `extension/src/popup/components/item-list.ts` — popup item list +6. `extension/src/popup/components/item-form.ts` — type-picker +7. `extension/src/shared/glyphs.ts` — existing glyph constants + +## Execution mode + +Use **subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.5.1-stream-a`. + +## Interface contract with DEV-B (settings component) + +DEV-B will deliver a settings component with these exports. **You must use exactly this signature** in `vault.ts` when wiring the settings view: + +```ts +// extension/src/popup/components/settings.ts +export async function renderSettings(container: HTMLElement): Promise; +export function teardownSettings(): void; +``` + +Your vault.ts should call `renderSettings(pane)` when the `#settings` route is active, and `teardownSettings()` when navigating away. You can proceed with a stub import while DEV-B's branch is in progress; it will be reconciled at merge time. + +## Scope and boundaries + +**In scope:** A1–A7 (layout, drawer, bottom sheet, type picker, glyphs, empty states, toast). + +**Out of scope:** Stream B and C work. If you hit a bug outside your scope, file a `## QUESTION TO PM` block. + +**Hard rules:** +- No emoji anywhere in `extension/src/`. If you see one while editing, replace it with the monochrome glyph. +- `glyphs.ts` is the single source of truth. No inline Unicode literals at call sites. +- Don't merge to main. The PM owns merges. + +## Coordination protocol + +``` +## STATUS UPDATE — DEV-A +Time: +Task: +Status: IN-PROGRESS | BLOCKED | REVIEW-READY +Summary: +Next: +``` + +--- + +## Files + +**Create:** +- `extension/src/shared/toast.ts` — shared notification module + +**Modify:** +- `extension/src/shared/glyphs.ts` — add GLYPH_VAULT_TAB + 7 per-type constants +- `extension/src/shared/__tests__/glyphs.test.ts` — add new constants to the test +- `extension/src/popup/components/item-list.ts` — glyph vault-btn, type icons, empty states, toast +- `extension/src/popup/components/item-form.ts` — TYPE_OPTIONS glyphs, polished type picker +- `extension/src/vault/vault.ts` — full layout rewrite +- `extension/src/vault/vault.css` — 3-column layout, drawer, bottom sheet, responsive +- `extension/src/popup/styles.css` — toast styles, type-icon pill, empty-state styles + +--- + +### Task 1: Add `GLYPH_VAULT_TAB` and per-type glyph constants + +**Files:** +- Modify: `extension/src/shared/glyphs.ts` +- Modify: `extension/src/shared/__tests__/glyphs.test.ts` + +- [ ] **Step 1: Update the failing test first** + +In `extension/src/shared/__tests__/glyphs.test.ts`, add to the existing test: + +```ts +it('exports GLYPH_VAULT_TAB as U+29C9', () => { + expect(glyphs.GLYPH_VAULT_TAB).toBe('⧉'); +}); + +it('exports per-type glyph constants', () => { + expect(glyphs.GLYPH_TYPE_LOGIN).toBe('◉'); + expect(glyphs.GLYPH_TYPE_SECURE_NOTE).toBe('◫'); + expect(glyphs.GLYPH_TYPE_TOTP).toBe('⊡'); + expect(glyphs.GLYPH_TYPE_CARD).toBe('▭'); + expect(glyphs.GLYPH_TYPE_IDENTITY).toBe('⌬'); + expect(glyphs.GLYPH_TYPE_KEY).toBe('⊹'); + expect(glyphs.GLYPH_TYPE_DOCUMENT).toBe('≡'); +}); + +it('per-type glyphs are single codepoints (no emoji)', () => { + const typeGlyphs = [ + glyphs.GLYPH_TYPE_LOGIN, glyphs.GLYPH_TYPE_SECURE_NOTE, glyphs.GLYPH_TYPE_TOTP, + glyphs.GLYPH_TYPE_CARD, glyphs.GLYPH_TYPE_IDENTITY, glyphs.GLYPH_TYPE_KEY, + glyphs.GLYPH_TYPE_DOCUMENT, + ]; + for (const g of typeGlyphs) { + expect([...g].length).toBe(1); + } +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run test 2>&1 | grep -E "FAIL|✗|×" +``` + +Expected: the new test cases fail (constants not exported yet). + +- [ ] **Step 3: Add constants to glyphs.ts** + +In `extension/src/shared/glyphs.ts`, add after the existing `GLYPH_NEXT` line: + +```ts +export const GLYPH_VAULT_TAB = '⧉'; // U+29C9 pop-out to fullscreen vault tab + +export const GLYPH_TYPE_LOGIN = '◉'; // login +export const GLYPH_TYPE_SECURE_NOTE = '◫'; // secure note +export const GLYPH_TYPE_TOTP = '⊡'; // totp / 2FA +export const GLYPH_TYPE_CARD = '▭'; // card +export const GLYPH_TYPE_IDENTITY = '⌬'; // identity +export const GLYPH_TYPE_KEY = '⊹'; // SSH / API key +export const GLYPH_TYPE_DOCUMENT = '≡'; // document +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run test 2>&1 | grep -E "PASS|✓|Tests" +``` + +Expected: all glyph tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/glyphs.ts extension/src/shared/__tests__/glyphs.test.ts +git commit -m "feat(ext/glyphs): add GLYPH_VAULT_TAB and per-type icon constants" +``` + +--- + +### Task 2: Replace `⤴` vault button in `item-list.ts` + +**Files:** +- Modify: `extension/src/popup/components/item-list.ts` + +- [ ] **Step 1: Update the import** + +In `item-list.ts`, find the imports section and add `GLYPH_VAULT_TAB` to the glyphs import: + +```ts +import { GLYPH_VAULT_TAB } from '../../shared/glyphs'; +``` + +- [ ] **Step 2: Replace the inline HTML entity** + +In `item-list.ts:69`, change: + +```ts +// Old: + + +// New: + +``` + +- [ ] **Step 3: Build and check for TS errors** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/item-list.ts +git commit -m "fix(ext/popup): replace inline ⤴ vault-tab button with GLYPH_VAULT_TAB" +``` + +--- + +### Task 3: Replace emoji `typeIcon()` in `item-list.ts` with glyph function + +**Files:** +- Modify: `extension/src/popup/components/item-list.ts` + +- [ ] **Step 1: Add glyph imports to item-list.ts** + +```ts +import { + GLYPH_VAULT_TAB, + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, + GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, +} from '../../shared/glyphs'; +``` + +- [ ] **Step 2: Replace `typeIcon()` in item-list.ts** + +The existing `typeIcon()` function (lines ~16–26) uses emoji. Replace entirely: + +```ts +function typeIcon(t: ItemType): string { + switch (t) { + case 'login': return GLYPH_TYPE_LOGIN; + case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; + case 'identity': return GLYPH_TYPE_IDENTITY; + case 'card': return GLYPH_TYPE_CARD; + case 'key': return GLYPH_TYPE_KEY; + case 'document': return GLYPH_TYPE_DOCUMENT; + case 'totp': return GLYPH_TYPE_TOTP; + } +} +``` + +Also replace the 📎 paperclip emoji in `buildRowsHtml()`: + +```ts +// Old: +${e.attachment_summaries.length > 0 ? ' 📎' : ''} + +// New: +${e.attachment_summaries.length > 0 ? ' ' : ''} +``` + +- [ ] **Step 3: Build and confirm no TS errors** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/item-list.ts +git commit -m "fix(ext/popup): replace emoji typeIcon with glyph constants in item-list" +``` + +--- + +### Task 4: Empty states in `item-list.ts` + +**Files:** +- Modify: `extension/src/popup/components/item-list.ts` +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Update `buildRowsHtml()` to use two distinct empty states** + +Current empty state is `'
no items
'`. Split into: + +```ts +function buildRowsHtml(): string { + const state = getState(); + const filtered = getFilteredEntries(); + + if (filtered.length === 0) { + if (state.searchQuery) { + return ` +
+ +
No results for "${escapeHtml(state.searchQuery)}"
+
Try a shorter search term.
+
+ `; + } + return ` +
+ +
No items yet
+
Press + to add your first item.
+
+ `; + } + + return filtered.map(([id, e], i) => ` +
+ ${escapeHtml(e.title)}${e.attachment_summaries.length > 0 ? ' ' : ''} + +
+ `).join(''); +} +``` + +- [ ] **Step 2: Add empty-state CSS to styles.css** + +In `extension/src/popup/styles.css`, add: + +```css +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 40px 20px; + text-align: center; +} + +.empty-state__icon { + font-size: 28px; + color: var(--text-muted, #8b949e); + margin-bottom: 12px; + display: block; +} + +.empty-state__title { + font-size: 13px; + font-weight: 600; + margin-bottom: 4px; +} + +.empty-state__hint { + font-size: 11px; + color: var(--text-muted, #8b949e); +} +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/item-list.ts extension/src/popup/styles.css +git commit -m "feat(ext/popup): empty states with glyph icons in item-list" +``` + +--- + +### Task 5: Polished type-picker in `item-form.ts` + +**Files:** +- Modify: `extension/src/popup/components/item-form.ts` + +- [ ] **Step 1: Replace emoji in TYPE_OPTIONS** + +Current `TYPE_OPTIONS` uses emoji icons. Replace: + +```ts +import { + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, + GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, +} from '../../shared/glyphs'; + +const TYPE_OPTIONS: Array<{ type: ItemType; icon: string; label: string; description: string }> = [ + { type: 'login', icon: GLYPH_TYPE_LOGIN, label: 'Login', description: 'Username + password' }, + { type: 'secure_note', icon: GLYPH_TYPE_SECURE_NOTE, label: 'Secure Note', description: 'Encrypted text note' }, + { type: 'identity', icon: GLYPH_TYPE_IDENTITY, label: 'Identity', description: 'Personal details' }, + { type: 'card', icon: GLYPH_TYPE_CARD, label: 'Card', description: 'Credit / debit card' }, + { type: 'key', icon: GLYPH_TYPE_KEY, label: 'SSH / API Key', description: 'Keys and tokens' }, + { type: 'document', icon: GLYPH_TYPE_DOCUMENT, label: 'Document', description: 'File attachment' }, + { type: 'totp', icon: GLYPH_TYPE_TOTP, label: 'TOTP', description: '2FA authenticator' }, +]; +``` + +- [ ] **Step 2: Upgrade `renderTypeSelection` to a 2-column card grid** + +Replace the current `renderTypeSelection` function's HTML: + +```ts +function renderTypeSelection(app: HTMLElement): void { + app.innerHTML = ` +
+
+ + New item + + ${isInTab() ? '' : ''} +
+
+ ${TYPE_OPTIONS.map((opt) => ` + + `).join('')} +
+
Esc back
+
+ `; + + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('popout-btn')?.addEventListener('click', popOutToTab); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') navigate('list'); + }, { once: true }); + + document.querySelectorAll('[data-type]').forEach((btn) => { + btn.addEventListener('click', () => { + const type = btn.dataset.type as ItemType; + setState({ newType: type }); + renderItemForm(app, 'add'); + }); + }); +} +``` + +- [ ] **Step 3: Add type-card CSS to styles.css** + +In `extension/src/popup/styles.css`: + +```css +.type-card-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.type-card { + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 10px 12px; + background: var(--bg-elevated, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 6px; + cursor: pointer; + text-align: left; + transition: border-color 0.15s; +} + +.type-card:hover { border-color: var(--gold, #b8860b); } + +.type-card__icon { font-size: 20px; margin-bottom: 4px; } +.type-card__label { font-size: 12px; font-weight: 600; } +.type-card__desc { font-size: 10px; color: var(--text-muted, #8b949e); margin-top: 2px; } +``` + +- [ ] **Step 4: Build and run tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +bun run test 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/popup/components/item-form.ts extension/src/popup/styles.css +git commit -m "feat(ext/popup): polished 2-column type-picker with glyph icons" +``` + +--- + +### Task 6: Toast system + +**Files:** +- Create: `extension/src/shared/toast.ts` +- Modify: `extension/src/popup/styles.css` +- Modify: `extension/src/vault/vault.css` + +- [ ] **Step 1: Write toast.ts** + +```ts +// extension/src/shared/toast.ts + +export function showToast( + message: string, + type: 'success' | 'error' | 'info' = 'info', + durationMs = 2500, +): void { + let container = document.querySelector('.relicario-toast-container'); + if (!container) { + container = document.createElement('div'); + container.className = 'relicario-toast-container'; + document.body.appendChild(container); + } + + const toast = document.createElement('div'); + toast.className = `relicario-toast relicario-toast--${type}`; + toast.textContent = message; + container.appendChild(toast); + + requestAnimationFrame(() => { + requestAnimationFrame(() => toast.classList.add('relicario-toast--visible')); + }); + + setTimeout(() => { + toast.classList.remove('relicario-toast--visible'); + toast.addEventListener('transitionend', () => toast.remove(), { once: true }); + }, durationMs); +} +``` + +- [ ] **Step 2: Add toast CSS to both stylesheets** + +In `extension/src/popup/styles.css` AND `extension/src/vault/vault.css`, add: + +```css +/* Toast notifications */ +.relicario-toast-container { + position: fixed; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + gap: 6px; + pointer-events: none; + z-index: 9999; +} + +/* Vault tab: position bottom-right instead */ +.vault-shell .relicario-toast-container { + left: auto; + right: 24px; + transform: none; +} + +.relicario-toast { + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; + pointer-events: none; +} + +.relicario-toast--visible { + opacity: 1; + transform: translateY(0); +} + +.relicario-toast--success { background: #1f4a24; color: #aff0b5; border: 1px solid #238636; } +.relicario-toast--error { background: #4a1f1f; color: #f0afaf; border: 1px solid #ab2b20; } +.relicario-toast--info { background: #1f2d4a; color: #afc8f0; border: 1px solid #1f6feb; } +``` + +- [ ] **Step 3: Replace sync-status div in item-list.ts with toast** + +In `item-list.ts`, find the sync button handler and replace the manual status update with toast: + +```ts +import { showToast } from '../../shared/toast'; + +// In the sync button click handler: +document.getElementById('sync-btn')?.addEventListener('click', async () => { + setState({ loading: true, error: null }); + const resp = await sendMessage({ type: 'sync' }); + if (resp.ok) { + const listResp = await sendMessage({ type: 'list_items' }); + if (listResp.ok) { + const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> }; + setState({ entries: data.items, loading: false }); + showToast('Synced', 'success'); + return; + } + setState({ loading: false, error: listResp.error }); + showToast(listResp.error ?? 'Sync failed', 'error'); + } else { + setState({ loading: false, error: resp.error }); + showToast(resp.error ?? 'Sync failed', 'error'); + } +}); +``` + +- [ ] **Step 4: Build and run tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +bun run test 2>&1 | tail -10 +``` + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/toast.ts extension/src/popup/styles.css extension/src/vault/vault.css extension/src/popup/components/item-list.ts +git commit -m "feat(ext): shared toast notification system" +``` + +--- + +### Task 7: `vault.ts` — replace emoji `typeIcon()` with glyphs + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Add glyph imports to vault.ts** + +Find the glyphs import line in vault.ts and add the type constants: + +```ts +import { + GLYPH_TRASH, GLYPH_DEVICES, GLYPH_SETTINGS, GLYPH_LOCK, GLYPH_NEXT, + GLYPH_TYPE_LOGIN, GLYPH_TYPE_SECURE_NOTE, GLYPH_TYPE_TOTP, + GLYPH_TYPE_CARD, GLYPH_TYPE_IDENTITY, GLYPH_TYPE_KEY, GLYPH_TYPE_DOCUMENT, +} from '../shared/glyphs'; +``` + +- [ ] **Step 2: Replace `typeIcon()` in vault.ts** + +Current `typeIcon()` (~line 61) uses Unicode escape sequences for emoji. Replace: + +```ts +function typeIcon(t: ItemType): string { + switch (t) { + case 'login': return GLYPH_TYPE_LOGIN; + case 'secure_note': return GLYPH_TYPE_SECURE_NOTE; + case 'identity': return GLYPH_TYPE_IDENTITY; + case 'card': return GLYPH_TYPE_CARD; + case 'key': return GLYPH_TYPE_KEY; + case 'document': return GLYPH_TYPE_DOCUMENT; + case 'totp': return GLYPH_TYPE_TOTP; + } +} +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.ts +git commit -m "fix(ext/vault): replace emoji typeIcon with glyph constants" +``` + +--- + +### Task 8: `vault.css` — 3-column layout rules + +**Files:** +- Modify: `extension/src/vault/vault.css` + +Add or replace the layout section. The existing `.vault-sidebar` and `.vault-pane` rules will be superseded. + +- [ ] **Step 1: Add 3-column shell + drawer CSS** + +In `vault.css`, add/replace the shell layout section: + +```css +/* === 3-column shell === */ +.vault-shell { + display: flex; + height: 100vh; + overflow: hidden; + background: var(--bg-page, #0d1117); +} + +/* Sidebar */ +.vault-sidebar { + width: 200px; + min-width: 200px; + display: flex; + flex-direction: column; + border-right: 1px solid var(--border, #30363d); + background: var(--bg-sidebar, #0d1117); + overflow-y: auto; + flex-shrink: 0; +} + +/* List pane (flex: 1, fills between sidebar and drawer) */ +.vault-list-pane { + flex: 1; + display: flex; + flex-direction: column; + overflow-y: auto; + min-width: 0; +} + +/* Detail drawer */ +.vault-drawer { + width: 440px; + min-width: 440px; + max-width: 440px; + border-left: 1px solid var(--border, #30363d); + background: var(--bg-elevated, #161b22); + overflow-y: auto; + transform: translateX(100%); + transition: transform 0.2s ease; + flex-shrink: 0; +} + +.vault-drawer--open { + transform: translateX(0); +} + +/* List rows */ +.vault-list-row { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + border-bottom: 1px solid var(--border-subtle, #21262d); + transition: background 0.1s; +} + +.vault-list-row:hover { background: var(--bg-hover, #161b22); } + +.vault-list-row--selected { + background: var(--bg-selected, #1c2d41); + border-left: 2px solid var(--gold, #b8860b); +} + +.vault-list-row__icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-elevated, #161b22); + border-radius: 6px; + border: 1px solid var(--border, #30363d); + font-size: 14px; + flex-shrink: 0; +} + +.vault-list-row--selected .vault-list-row__icon { border-color: var(--gold, #b8860b); } + +.vault-list-row__text { flex: 1; min-width: 0; } + +.vault-list-row__title { + font-size: 13px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vault-list-row__subtitle { + font-size: 11px; + color: var(--text-muted, #8b949e); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 1px; +} + +.vault-list-row__age { + font-size: 10px; + color: var(--text-dim, #6e7681); + flex-shrink: 0; +} + +/* Bottom sheet */ +.vault-bottom-sheet-scrim { + position: absolute; + inset: 0 0 0 200px; /* exclude sidebar */ + background: rgba(0,0,0,0.5); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; + z-index: 100; +} + +.vault-bottom-sheet-scrim--visible { + opacity: 1; + pointer-events: auto; +} + +.vault-bottom-sheet { + position: absolute; + bottom: 0; + left: 200px; /* exclude sidebar */ + right: 0; + background: var(--bg-elevated, #161b22); + border-top: 1px solid var(--border, #30363d); + border-radius: 12px 12px 0 0; + padding: 16px 24px 24px; + transform: translateY(100%); + transition: transform 0.25s ease; + z-index: 101; + max-height: 60vh; + overflow-y: auto; +} + +.vault-bottom-sheet--open { transform: translateY(0); } + +.vault-bottom-sheet__handle { + width: 40px; + height: 4px; + background: var(--border, #30363d); + border-radius: 2px; + margin: 0 auto 16px; +} + +.vault-bottom-sheet__title { + font-size: 14px; + font-weight: 600; + margin-bottom: 16px; + text-align: center; +} + +.vault-type-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 10px; +} + +.vault-type-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 12px 8px; + background: var(--bg-page, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 8px; + cursor: pointer; + transition: border-color 0.15s; + gap: 6px; +} + +.vault-type-card:hover { border-color: var(--gold, #b8860b); } + +.vault-type-card__icon { font-size: 28px; } +.vault-type-card__name { font-size: 11px; color: var(--text-muted, #8b949e); } + +/* Drawer header and body */ +.vault-drawer__header { + display: flex; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--border, #30363d); + gap: 8px; +} + +.vault-drawer__type-pill { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 2px 8px; + background: var(--bg-page, #0d1117); + border: 1px solid var(--border, #30363d); + border-radius: 4px; + color: var(--text-muted, #8b949e); +} + +.vault-drawer__actions { display: flex; gap: 6px; margin-left: auto; } + +.vault-drawer__close { + background: transparent; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-muted, #8b949e); + padding: 4px 6px; +} + +.vault-drawer__body { padding: 20px 20px 16px; } + +.vault-drawer__title { font-size: 18px; font-weight: 700; margin-bottom: 4px; } +.vault-drawer__subtitle { font-size: 12px; color: var(--text-muted, #8b949e); margin-bottom: 16px; } + +.vault-drawer__field-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.vault-drawer__field-grid > .vault-drawer__field--full { grid-column: 1 / -1; } + +.vault-drawer__field-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted, #8b949e); + margin-bottom: 2px; +} + +.vault-drawer__field-value { + font-size: 13px; + word-break: break-all; +} + +/* === Responsive === */ +@media (max-width: 960px) { + .vault-drawer { + position: absolute; + right: 0; + top: 0; + height: 100%; + } +} + +@media (max-width: 720px) { + .vault-sidebar { + width: 48px; + min-width: 48px; + } + .vault-sidebar__category-label, + .vault-sidebar__category-count, + .vault-sidebar__nav-label { + display: none; + } + .vault-sidebar__nav-item { justify-content: center; padding: 10px 0; } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/vault/vault.css +git commit -m "feat(ext/vault): 3-column layout CSS — drawer, bottom sheet, list rows, responsive" +``` + +--- + +### Task 9: `vault.ts` — 3-column shell + sidebar category nav + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +This is the largest task. Rewrite `renderShell()`, `renderSidebarList()`, and add the list pane renderer. + +- [ ] **Step 1: Add `drawerOpen` and `bottomSheetOpen` to VaultState** + +In the `VaultState` interface, add: + +```ts +drawerOpen: boolean; +bottomSheetOpen: boolean; +``` + +In the initial state object, add: + +```ts +drawerOpen: false, +bottomSheetOpen: false, +``` + +- [ ] **Step 2: Rewrite `renderShell()` for 3-column layout** + +Replace the existing `renderShell()` function: + +```ts +function renderShell(app: HTMLElement): void { + if (!app.querySelector('.vault-shell')) { + app.innerHTML = ` +
+
+
+ + Relicario +
+ + +
+ + + + +
+
+
+
+
+
+
+ `; + wireSidebar(); + wireBottomSheet(); + } + + renderSidebarCategories(); + renderListPane(); + if (state.drawerOpen && state.selectedItem) { + renderDrawer(state.selectedItem); + } +} +``` + +**Note:** The `⌬ devices` nav button is intentionally removed here. Once Stream B's Security section lands, devices are accessible via Settings → Security. Until then, users can access devices via the old popup route (still present). Confirm with PM before removing if uncertain. + +- [ ] **Step 3: Rewrite `renderSidebarList()` → `renderSidebarCategories()`** + +Rename the function and change it to show type sections with counts (not per-item rows): + +```ts +function renderSidebarCategories(): void { + const container = document.getElementById('vault-categories'); + if (!container) return; + + const filtered = getFilteredEntries(); + const typeOrder: ItemType[] = ['login', 'secure_note', 'identity', 'card', 'key', 'document', 'totp']; + + const allCount = filtered.length; + const isAllActive = !state.activeGroup && state.view === 'list'; + + let html = ` + + `; + + for (const t of typeOrder) { + const count = filtered.filter(([, e]) => e.type === t).length; + if (count === 0 && allCount > 0) continue; // hide empty sections unless vault is empty + const isActive = state.activeGroup === t; + html += ` + + `; + } + + container.innerHTML = html; + + container.querySelectorAll('.vault-category-row').forEach((btn) => { + btn.addEventListener('click', () => { + state.activeGroup = btn.dataset.group || null; + state.drawerOpen = false; + state.selectedId = null; + state.selectedItem = null; + renderSidebarCategories(); + renderListPane(); + closeDrawer(); + }); + }); +} +``` + +Add to vault.css (after Task 8): + +```css +.vault-category-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 12px; + background: transparent; + border: none; + cursor: pointer; + color: inherit; + font-size: 13px; + text-align: left; +} + +.vault-category-row:hover { background: var(--bg-hover, #161b22); } +.vault-category-row--active { background: var(--bg-selected, #1c2d41); } +.vault-category-row__icon { font-size: 14px; flex-shrink: 0; } +.vault-category-row__label { flex: 1; } +.vault-category-row__count { font-size: 11px; color: var(--text-muted, #8b949e); } +``` + +- [ ] **Step 4: Add `renderListPane()`** + +```ts +function renderListPane(): void { + const pane = document.getElementById('vault-list-pane'); + if (!pane) return; + + const group = state.activeGroup as ItemType | null; + let items = getFilteredEntries(); + if (group) items = items.filter(([, e]) => e.type === group); + + if (items.length === 0) { + pane.innerHTML = ` +
+ +
${state.searchQuery ? `No results for "${escapeHtml(state.searchQuery)}"` : 'No items yet'}
+
${state.searchQuery ? 'Try a shorter search term.' : 'Click + new item to get started.'}
+
+ `; + return; + } + + pane.innerHTML = items.map(([id, e]) => { + const sel = id === state.selectedId ? ' vault-list-row--selected' : ''; + const subtitle = e.icon_hint ?? (e.tags.length > 0 ? e.tags.join(', ') : ''); + const modifiedAgo = e.modified ? relativeTime(e.modified) : ''; + return ` +
+ +
+
${escapeHtml(e.title)}
+ ${subtitle ? `
${escapeHtml(subtitle)}
` : ''} +
+ ${modifiedAgo ? `
${escapeHtml(modifiedAgo)}
` : ''} +
+ `; + }).join(''); + + pane.querySelectorAll('.vault-list-row').forEach((row) => { + row.addEventListener('click', async () => { + await selectItemForDrawer(row.dataset.id!); + }); + }); +} + +function relativeTime(unixSec: number): string { + const diffS = Math.floor(Date.now() / 1000) - unixSec; + if (diffS < 60) return 'just now'; + if (diffS < 3600) return `${Math.floor(diffS / 60)}m ago`; + if (diffS < 86400) return `${Math.floor(diffS / 3600)}h ago`; + return `${Math.floor(diffS / 86400)}d ago`; +} +``` + +- [ ] **Step 5: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/vault/vault.ts extension/src/vault/vault.css +git commit -m "feat(ext/vault): 3-column shell — sidebar category nav + list pane" +``` + +--- + +### Task 10: `vault.ts` — detail drawer + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Add `selectItemForDrawer()` and drawer render/close functions** + +```ts +async function selectItemForDrawer(id: ItemId): Promise { + const resp = await sendMessage({ type: 'get_item', id }); + if (!resp.ok) return; + const data = resp.data as { item: Item }; + state.selectedId = id; + state.selectedItem = data.item; + state.drawerOpen = true; + renderSidebarCategories(); + renderListPane(); + renderDrawer(data.item); + openDrawer(); +} + +function openDrawer(): void { + document.getElementById('vault-drawer')?.classList.add('vault-drawer--open'); +} + +function closeDrawer(): void { + state.drawerOpen = false; + state.selectedId = null; + state.selectedItem = null; + document.getElementById('vault-drawer')?.classList.remove('vault-drawer--open'); +} + +function renderDrawer(item: Item): void { + const drawer = document.getElementById('vault-drawer'); + if (!drawer) return; + + const coreFields = getDrawerCoreFields(item); + + drawer.innerHTML = ` +
+ ${item.type.replace('_', ' ').toUpperCase()} +
+ + +
+
+
+
${escapeHtml(item.title)}
+ ${item.core && 'url' in item.core ? `
${escapeHtml((item.core as { url?: string }).url ?? '')}
` : ''} +
+ ${coreFields.map(([label, value, full]) => ` +
+
${escapeHtml(label)}
+
${escapeHtml(value)}
+
+ `).join('')} +
+
+ `; + + document.getElementById('drawer-close-btn')?.addEventListener('click', () => { + closeDrawer(); + renderListPane(); + }); + + document.getElementById('drawer-edit-btn')?.addEventListener('click', () => { + setHash('edit', state.selectedId!); + renderPane(); + }); +} + +function getDrawerCoreFields(item: Item): Array<[string, string, boolean]> { + // Returns [label, value, fullWidth] tuples for the core type fields + const core = item.core as Record; + if (!core) return []; + const fields: Array<[string, string, boolean]> = []; + if ('username' in core) fields.push(['username', String(core.username ?? ''), false]); + if ('password' in core) fields.push(['password', '••••••••', false]); + if ('url' in core) fields.push(['url', String(core.url ?? ''), true]); + if ('number' in core) fields.push(['number', String(core.number ?? ''), false]); + if ('expiry' in core) fields.push(['expiry', String(core.expiry ?? ''), false]); + if ('cardholder' in core) fields.push(['cardholder', String(core.cardholder ?? ''), true]); + if (item.notes) fields.push(['notes', item.notes, true]); + return fields; +} +``` + +- [ ] **Step 2: Add Esc key handler to close drawer** + +In `wireSidebar()` or the shell setup, add: + +```ts +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && state.drawerOpen) { + closeDrawer(); + renderListPane(); + } +}); +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.ts +git commit -m "feat(ext/vault): detail drawer — open/close state + core fields display" +``` + +--- + +### Task 11: `vault.ts` — bottom sheet for new item type picker + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Add `wireBottomSheet()` and sheet open/close functions** + +```ts +const BOTTOM_SHEET_TYPES: Array<{ type: ItemType; label: string }> = [ + { type: 'login', label: 'Login' }, + { type: 'secure_note', label: 'Secure Note' }, + { type: 'totp', label: 'TOTP' }, + { type: 'card', label: 'Card' }, + { type: 'identity', label: 'Identity' }, + { type: 'key', label: 'SSH / API Key' }, + { type: 'document', label: 'Document' }, +]; + +function openBottomSheet(): void { + const sheet = document.getElementById('vault-bottom-sheet')!; + const scrim = document.getElementById('vault-sheet-scrim')!; + + sheet.innerHTML = ` +
+
New item — choose type
+
+ ${BOTTOM_SHEET_TYPES.map((t) => ` + + `).join('')} +
+ `; + + sheet.classList.add('vault-bottom-sheet--open'); + scrim.classList.add('vault-bottom-sheet-scrim--visible'); + state.bottomSheetOpen = true; + + sheet.querySelectorAll('[data-type]').forEach((btn) => { + btn.addEventListener('click', () => { + const type = btn.dataset.type as ItemType; + closeBottomSheet(); + state.newType = type; + setHash('add', type); + renderPane(); + }); + }); +} + +function closeBottomSheet(): void { + document.getElementById('vault-bottom-sheet')?.classList.remove('vault-bottom-sheet--open'); + document.getElementById('vault-sheet-scrim')?.classList.remove('vault-bottom-sheet-scrim--visible'); + state.bottomSheetOpen = false; +} + +function wireBottomSheet(): void { + document.getElementById('vault-sheet-scrim')?.addEventListener('click', closeBottomSheet); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && state.bottomSheetOpen) closeBottomSheet(); + }); +} +``` + +- [ ] **Step 2: Update the `data-nav="add"` handler to open the sheet** + +In `wireSidebar()`, find the `nav === 'add'` branch and change it to call `openBottomSheet()` instead of directly calling `setHash('add')`: + +```ts +if (nav === 'add') { + openBottomSheet(); + return; +} +``` + +- [ ] **Step 3: Build and run tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +bun run test 2>&1 | tail -10 +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.ts +git commit -m "feat(ext/vault): bottom sheet type picker for new item" +``` + +--- + +### Task 12: Wire settings component (interface contract with DEV-B) + +**Files:** +- Modify: `extension/src/vault/vault.ts` + +- [ ] **Step 1: Add settings import** + +Add to vault.ts imports: + +```ts +import { renderSettings, teardownSettings } from '../popup/components/settings'; +``` + +- [ ] **Step 2: Wire in `renderPane()` for the settings view** + +Find the `renderPane()` function and ensure the `settings` case calls `renderSettings`: + +```ts +case 'settings': + teardownSettings(); + await renderSettings(pane); + return; +``` + +And ensure `teardownSettings()` is called when navigating away from settings (wherever view transitions happen). + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run build 2>&1 | grep "error TS" +``` + +If DEV-B's settings.ts doesn't yet export `renderSettings` / `teardownSettings`, add a temporary stub to `settings.ts` on this branch to unblock compilation — note it clearly in a comment for DEV-B. This stub will be replaced at merge time. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/vault/vault.ts +git commit -m "feat(ext/vault): wire renderSettings / teardownSettings from settings component" +``` + +--- + +### Task 13: Full build + test pass + +- [ ] **Step 1: Run all tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-a/extension && bun run test 2>&1 | tail -20 +``` + +Expected: all existing tests pass. If any fail, fix before proceeding. + +- [ ] **Step 2: Build both targets** + +```bash +bun run build 2>&1 | tail -10 +bun run build:firefox 2>&1 | tail -10 +``` + +Expected: both build clean (only pre-existing bundle-size warnings). + +- [ ] **Step 3: Grep for remaining emoji in extension/src/** + +```bash +grep -rn '\U0001F\|🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️\|🖥\|🔐\|📎' /home/alee/Sources/relicario.v0.5.1-stream-a/extension/src/ 2>/dev/null +``` + +Expected: no output (all emoji replaced with glyphs). + +- [ ] **Step 4: Commit any remaining fixes, then open PR** + +```bash +gh pr create --title "feat: fullscreen 3-column layout + popup polish (Stream A)" --base main +``` + +- [ ] **Step 5: Post status to PM** + +``` +## STATUS UPDATE — DEV-A +Time: +Task: 13 of 13 +Status: REVIEW-READY +Summary: All 13 tasks complete. PR open. Build clean. All tests pass. No emoji remaining in extension/src/. +Next: waiting for PM +``` diff --git a/docs/superpowers/coordination/v0.5.1-dev-b-prompt.md b/docs/superpowers/coordination/v0.5.1-dev-b-prompt.md new file mode 100644 index 0000000..e87c0af --- /dev/null +++ b/docs/superpowers/coordination/v0.5.1-dev-b-prompt.md @@ -0,0 +1,1074 @@ +# Dev B Kickoff Prompt — v0.5.1 Stream B (Settings UX Redesign) + +> **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. + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are a **senior developer** owning Stream B for the Relicario v0.5.1 release. Stream B replaces the current flat settings dump with a unified left-nav sectioned settings page, integrating device and vault settings into a single coherent view. + +**Goal:** Replace `settings.ts` (flat dump) + `settings-vault.ts` (flat dump) with a single settings component that has a left-nav sidebar (Autofill, Display, Security, Generator, Retention, Backup, Import). Wire into `vault.ts` via the agreed interface. Wire `settings-security.ts` from DEV-C into the Security section. + +**Architecture:** All changes are in the extension. The settings component lives in `settings.ts` (rewrite). Section content for vault-level settings moves from `settings-vault.ts` into inline render functions within the new layout. DEV-C owns `settings-security.ts`; you stub it and import it. + +**Tech Stack:** TypeScript, vitest, webpack/bun. + +--- + +## Setup (do this first) + +```bash +cd /home/alee/Sources/relicario +git fetch +git checkout main +git pull +git worktree add ../relicario.v0.5.1-stream-b -b feature/v0.5.1-stream-b-settings +cd ../relicario.v0.5.1-stream-b +pwd # should print /home/alee/Sources/relicario.v0.5.1-stream-b +``` + +**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.5.1-stream-b`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.v0.5.1-stream-b`. + +Today: 2026-05-03. Project rules in `CLAUDE.md` apply. + +## Required reading + +1. `CLAUDE.md` — project rules +2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — spec sections B1–B8 +3. `extension/src/popup/components/settings.ts` — current flat settings (your rewrite target) +4. `extension/src/popup/components/settings-vault.ts` — vault settings content to decompose +5. `extension/src/popup/styles.css` — existing CSS (add to, don't break) + +## Execution mode + +Use **subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.5.1-stream-b`. + +## Interface contracts + +### With DEV-A (vault.ts wiring) + +DEV-A imports these exact exports from `settings.ts` in vault.ts. **You must use exactly these signatures:** + +```ts +export async function renderSettings(container: HTMLElement): Promise; +export function teardownSettings(): void; +``` + +DEV-A calls `renderSettings(pane)` when the settings view is active and `teardownSettings()` when navigating away. + +### With DEV-C (settings-security.ts) + +DEV-C owns `settings-security.ts`. You import it for the Security section. The agreed interface: + +```ts +// extension/src/popup/components/settings-security.ts +export async function renderSecuritySection( + container: HTMLElement, + sessionHandle: number | null, +): Promise; +export function teardownSecuritySection(): void; +``` + +**Task 1** creates a stub `settings-security.ts` with this interface (no-op implementations). DEV-C will replace it on their branch; the real implementation lands when C merges. + +## Scope and boundaries + +**In scope:** B1–B8 (settings skeleton, all 7 sections, cleanup of old settings-vault.ts surface). + +**Out of scope:** Stream A and C work. The Security section's QR and device UI is DEV-C's responsibility — you only wire the import. + +**Hard rules:** +- `renderSettings` / `teardownSettings` must be exported with these exact names and signatures. +- Device sections read/write `chrome.storage.local`. Vault sections call `sendMessage` to the service worker. +- Don't merge to main. The PM owns merges. + +## Coordination protocol + +``` +## STATUS UPDATE — DEV-B +Time: +Task: +Status: IN-PROGRESS | BLOCKED | REVIEW-READY +Summary: +Next: +``` + +--- + +## Files + +**Create:** +- `extension/src/popup/components/settings-security.ts` — stub (DEV-C replaces real implementation) +- `extension/src/popup/components/__tests__/settings-nav.test.ts` — left-nav structure tests + +**Modify:** +- `extension/src/popup/components/settings.ts` — full rewrite as sectioned layout +- `extension/src/popup/components/settings-vault.ts` — decompose into section functions; may be removed at the end +- `extension/src/popup/styles.css` — settings-nav CSS + section styles + +--- + +### Task 1: Stub `settings-security.ts` + +This stub satisfies the import used in the Security section (Task 6). DEV-C replaces it with the real implementation. + +**Files:** +- Create: `extension/src/popup/components/settings-security.ts` + +- [ ] **Step 1: Write the stub** + +```ts +// extension/src/popup/components/settings-security.ts +// Stub — real implementation provided by Stream C (DEV-C). + +export async function renderSecuritySection( + container: HTMLElement, + _sessionHandle: number | null, +): Promise { + container.innerHTML = ` +
+ Security settings — loading… +
+ `; +} + +export function teardownSecuritySection(): void { + // no-op in stub +} +``` + +- [ ] **Step 2: Build to confirm no TS errors** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/popup/components/settings-security.ts +git commit -m "chore(ext/settings): stub settings-security.ts (DEV-C replaces implementation)" +``` + +--- + +### Task 2: Settings left-nav skeleton + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` +- Modify: `extension/src/popup/styles.css` + +The new settings.ts is a full rewrite. It replaces the current flat dump with a two-panel layout: a 148px left-nav sidebar + content area. + +- [ ] **Step 1: Write the failing test** + +Create `extension/src/popup/components/__tests__/settings-nav.test.ts`: + +```ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock chrome.storage.local +const storageMock: Record = {}; +vi.stubGlobal('chrome', { + storage: { + local: { + get: vi.fn((keys, cb) => cb(typeof keys === 'string' ? { [keys]: storageMock[keys] } : Object.fromEntries((keys as string[]).map(k => [k, storageMock[k]])))), + set: vi.fn((data, cb) => { Object.assign(storageMock, data); cb?.(); }), + }, + }, +}); + +// We can't easily test the full render (no DOM env yet), so we test the +// structural contract — the exported function names exist. +import * as settingsMod from '../settings'; + +describe('settings module contract', () => { + it('exports renderSettings as an async function', () => { + expect(typeof settingsMod.renderSettings).toBe('function'); + }); + + it('exports teardownSettings as a function', () => { + expect(typeof settingsMod.teardownSettings).toBe('function'); + }); +}); +``` + +- [ ] **Step 2: Run to confirm the test currently passes (contract already met)** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run test 2>&1 | grep "settings-nav" +``` + +The test may already pass if current settings.ts exports `renderSettings`. If so, proceed — the test ensures the contract is preserved through the rewrite. + +- [ ] **Step 3: Rewrite settings.ts with the new sectioned layout** + +```ts +// extension/src/popup/components/settings.ts + +import { sendMessage, escapeHtml } from '../../shared/state'; +import type { DeviceSettings, VaultSettings } from '../../shared/types'; +import { + loadColorScheme, saveColorScheme, resetColorScheme, + DEFAULT_DIGIT_COLOR, DEFAULT_SYMBOL_COLOR, +} from '../../shared/color-scheme'; +import { colorizePassword } from '../../shared/password-coloring'; +import { openGeneratorPanel, closeGeneratorPanel } from './generator-panel'; +import { renderSecuritySection, teardownSecuritySection } from './settings-security'; + +type SettingsSection = + | 'autofill' + | 'display' + | 'security' + | 'generator' + | 'retention' + | 'backup' + | 'import'; + +const NAV_ITEMS: Array<{ id: SettingsSection; icon: string; label: string; group: 'device' | 'vault' }> = [ + { id: 'autofill', icon: '⊙', label: 'Autofill', group: 'device' }, + { id: 'display', icon: '◈', label: 'Display', group: 'device' }, + { id: 'security', icon: '◉', label: 'Security', group: 'vault' }, + { id: 'generator', icon: '↻', label: 'Generator', group: 'vault' }, + { id: 'retention', icon: '▦', label: 'Retention', group: 'vault' }, + { id: 'backup', icon: '⤓', label: 'Backup', group: 'vault' }, + { id: 'import', icon: '≡', label: 'Import', group: 'vault' }, +]; + +let activeSection: SettingsSection = 'autofill'; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; +let pendingVaultSettings: VaultSettings | null = null; + +export async function renderSettings(container: HTMLElement): Promise { + container.innerHTML = ` +
+ +
+
+ `; + + wireNav(); + await renderSection(activeSection); +} + +export function teardownSettings(): void { + closeGeneratorPanel(); + teardownSecuritySection(); + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingVaultSettings = null; +} + +function navItemHtml(item: (typeof NAV_ITEMS)[0]): string { + const active = item.id === activeSection ? ' settings-nav__item--active' : ''; + return ` + + `; +} + +function wireNav(): void { + document.getElementById('settings-nav')?.querySelectorAll('[data-section]') + .forEach((btn) => { + btn.addEventListener('click', async () => { + teardownSecuritySection(); + closeGeneratorPanel(); + activeSection = btn.dataset.section as SettingsSection; + // Update active state + document.querySelectorAll('.settings-nav__item').forEach(b => b.classList.remove('settings-nav__item--active')); + btn.classList.add('settings-nav__item--active'); + await renderSection(activeSection); + }); + }); +} + +async function renderSection(section: SettingsSection): Promise { + const content = document.getElementById('settings-content'); + if (!content) return; + + switch (section) { + case 'autofill': return renderAutofillSection(content); + case 'display': return renderDisplaySection(content); + case 'security': return renderSecuritySection(content, null); // sessionHandle: null until wired from vault.ts + case 'generator': return renderGeneratorSection(content); + case 'retention': return renderRetentionSection(content); + case 'backup': return renderBackupSection(content); + case 'import': return renderImportSection(content); + } +} +``` + +- [ ] **Step 4: Add settings layout CSS** + +In `extension/src/popup/styles.css`: + +```css +/* === Settings layout === */ +.settings-layout { + display: flex; + height: 100%; + overflow: hidden; +} + +.settings-nav { + width: 148px; + min-width: 148px; + border-right: 1px solid var(--border, #30363d); + padding: 12px 0; + overflow-y: auto; + flex-shrink: 0; +} + +.settings-nav__group-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted, #8b949e); + padding: 8px 12px 4px; +} + +.settings-nav__item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px; + background: transparent; + border: none; + cursor: pointer; + font-size: 13px; + color: inherit; + text-align: left; +} + +.settings-nav__item:hover { background: var(--bg-hover, #161b22); } +.settings-nav__item--active { background: var(--bg-selected, #1c2d41); } + +.settings-nav__icon { font-size: 14px; flex-shrink: 0; } + +.settings-content { + flex: 1; + overflow-y: auto; + padding: 20px 24px; + min-width: 0; +} + +/* Setting row (label + description + control) */ +.setting-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 12px 0; + border-bottom: 1px solid var(--border-subtle, #21262d); +} + +.setting-row:last-child { border-bottom: none; } + +.setting-row__info { flex: 1; } +.setting-row__title { font-size: 13px; font-weight: 500; } +.setting-row__desc { font-size: 11px; color: var(--text-muted, #8b949e); margin-top: 2px; } +.setting-row__control { flex-shrink: 0; } + +/* Section headings */ +.settings-section-title { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted, #8b949e); + margin: 0 0 12px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border, #30363d); +} + +/* Setting card (used by Security section) */ +.setting-card { + padding: 12px 16px; + border-radius: 6px; + border: 1px solid var(--border, #30363d); + margin-bottom: 12px; +} + +.setting-card--ok { border-color: var(--success, #238636); background: rgba(35, 134, 54, 0.06); } +.setting-card--warn { border-color: var(--gold, #b8860b); background: rgba(184, 134, 11, 0.06); } + +.setting-card__status { font-size: 13px; margin-bottom: 8px; } +.setting-card__actions { display: flex; gap: 8px; } +``` + +- [ ] **Step 5: Build and run tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +bun run test 2>&1 | grep "settings-nav" +``` + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/popup/components/settings.ts extension/src/popup/styles.css extension/src/popup/components/__tests__/settings-nav.test.ts +git commit -m "feat(ext/settings): settings left-nav skeleton with section routing" +``` + +--- + +### Task 3: Autofill section (Device) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +- [ ] **Step 1: Implement `renderAutofillSection()`** + +Add this function to settings.ts: + +```ts +async function renderAutofillSection(content: HTMLElement): Promise { + const [settingsResp, blacklistResp] = await Promise.all([ + sendMessage({ type: 'get_settings' }), + sendMessage({ type: 'get_blacklist' }), + ]); + + const settings: DeviceSettings = settingsResp.ok + ? (settingsResp.data as { settings: DeviceSettings }).settings + : { captureEnabled: false, captureStyle: 'bar' }; + + const blacklist: string[] = blacklistResp.ok + ? (blacklistResp.data as { blacklist: string[] }).blacklist + : []; + + content.innerHTML = ` +

Capture

+
+
+
Auto-detect logins
+
Show a prompt when a login form is detected.
+
+
+ +
+
+
+
+
Prompt style
+
How to prompt when a login is detected.
+
+
+ + +
+
+ +

Blocked sites

+
+ ${blacklist.length > 0 + ? blacklist.map((h) => ` +
+
+
${escapeHtml(h)}
+
+ +
+ `).join('') + : '

No blocked sites.

'} +
+
+ + +
+ `; + + document.getElementById('capture-enabled')?.addEventListener('change', async (e) => { + const enabled = (e.target as HTMLInputElement).checked; + await sendMessage({ type: 'save_settings', settings: { ...settings, captureEnabled: enabled } }); + }); + + document.getElementById('style-bar')?.addEventListener('click', async () => { + await sendMessage({ type: 'save_settings', settings: { ...settings, captureStyle: 'bar' } }); + renderAutofillSection(content); + }); + + document.getElementById('style-toast')?.addEventListener('click', async () => { + await sendMessage({ type: 'save_settings', settings: { ...settings, captureStyle: 'toast' } }); + renderAutofillSection(content); + }); + + content.querySelectorAll('.remove-bl').forEach((btn) => { + btn.addEventListener('click', async () => { + const host = btn.dataset.hostname!; + await sendMessage({ type: 'remove_blacklist_entry', hostname: host }); + renderAutofillSection(content); + }); + }); + + document.getElementById('bl-add-btn')?.addEventListener('click', async () => { + const input = document.getElementById('bl-add-input') as HTMLInputElement; + const val = input.value.trim(); + if (!val) return; + await sendMessage({ type: 'add_blacklist_entry', hostname: val }); + input.value = ''; + renderAutofillSection(content); + }); +} +``` + +- [ ] **Step 2: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): autofill section (capture toggle + blacklist)" +``` + +--- + +### Task 4: Display section (Device) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +- [ ] **Step 1: Implement `renderDisplaySection()`** + +Extract the existing password-coloring UI from the old settings.ts and move it into the Display section: + +```ts +function renderDisplaySection(content: HTMLElement): void { + const scheme = loadColorScheme(); + content.innerHTML = ` +

Password coloring

+
+
+
Digit color
+
+
+ +
+
+
+
+
Symbol color
+
+
+ +
+
+
+
+
Preview
+
+
+ ${colorizePassword('Aa1!Bb2@', scheme)} +
+
+
+ +
+ `; + + function updatePreview(): void { + const scheme2 = loadColorScheme(); + const preview = document.getElementById('color-preview'); + if (preview) preview.innerHTML = colorizePassword('Aa1!Bb2@', scheme2); + } + + document.getElementById('digit-color')?.addEventListener('input', (e) => { + const color = (e.target as HTMLInputElement).value; + saveColorScheme({ ...loadColorScheme(), digitColor: color }); + updatePreview(); + }); + + document.getElementById('symbol-color')?.addEventListener('input', (e) => { + const color = (e.target as HTMLInputElement).value; + saveColorScheme({ ...loadColorScheme(), symbolColor: color }); + updatePreview(); + }); + + document.getElementById('reset-colors')?.addEventListener('click', () => { + resetColorScheme(); + renderDisplaySection(content); + }); +} +``` + +- [ ] **Step 2: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): display section (password coloring)" +``` + +--- + +### Task 5: Security section stub wire-up + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +The Security section calls `renderSecuritySection()` from `settings-security.ts` (stub from Task 1; DEV-C's real implementation replaces it). + +The stub's signature is `renderSecuritySection(container, sessionHandle)`. For now, pass `null` as the session handle — the vault.ts wiring from DEV-A will eventually thread the real handle through. This is acceptable for the stub phase. + +- [ ] **Step 1: Verify `renderSection('security')` calls `renderSecuritySection`** + +The `renderSection()` function already has: + +```ts +case 'security': return renderSecuritySection(content, null); +``` + +This is already wired in Task 2's settings.ts rewrite. Confirm it builds cleanly. + +- [ ] **Step 2: Build and confirm** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 3: Commit (only if additional wiring was needed)** + +If you had to add anything, commit: + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): wire security section to settings-security.ts stub" +``` + +--- + +### Task 6: Generator section (Vault) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +Extract the generator defaults content from `settings-vault.ts`. No functional changes — just consistent styling using `setting-row` pattern. + +- [ ] **Step 1: Read the current generator content in settings-vault.ts** + +```bash +grep -n "generator\|generate\|Generator" extension/src/popup/components/settings-vault.ts | head -20 +``` + +- [ ] **Step 2: Implement `renderGeneratorSection()`** + +```ts +async function renderGeneratorSection(content: HTMLElement): Promise { + content.innerHTML = '

Loading…

'; + const resp = await sendMessage({ type: 'get_vault_settings' }); + if (!resp.ok) { + content.innerHTML = `

Failed to load: ${escapeHtml(resp.error ?? 'unknown')}

`; + return; + } + const settings = (resp.data as { settings: VaultSettings }).settings; + + content.innerHTML = ` +

Generator defaults

+
+
+
Configure generator
+
Password length, character classes, passphrase word count.
+
+
+ +
+
+ `; + + document.getElementById('open-generator-panel')?.addEventListener('click', (e) => { + openGeneratorPanel(e.currentTarget as HTMLElement, settings.generator_defaults, async (req) => { + await sendMessage({ type: 'save_vault_settings', settings: { ...settings, generator_defaults: req } }); + }); + }); +} +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): generator section (vault defaults)" +``` + +--- + +### Task 7: Retention section (Vault) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +Extract retention content from `settings-vault.ts`. + +- [ ] **Step 1: Read current retention helpers in settings-vault.ts** + +```bash +grep -n "retention\|Retention\|trash\|history" extension/src/popup/components/settings-vault.ts | head -30 +``` + +Note the helper functions: `trashRetentionToValue`, `valueToTrashRetention`, `historyRetentionToValue`, `valueToHistoryRetention`. Copy these into settings.ts or import from settings-vault.ts if it stays as a helper module. + +- [ ] **Step 2: Implement `renderRetentionSection()`** + +```ts +async function renderRetentionSection(content: HTMLElement): Promise { + content.innerHTML = '

Loading…

'; + const resp = await sendMessage({ type: 'get_vault_settings' }); + if (!resp.ok) { + content.innerHTML = `

Failed to load: ${escapeHtml(resp.error ?? 'unknown')}

`; + return; + } + const settings = (resp.data as { settings: VaultSettings }).settings; + pendingVaultSettings = { ...settings }; + + content.innerHTML = ` +

Trash retention

+
+
+
Keep deleted items for
+
Items in trash older than this are permanently deleted on the next sync.
+
+
+ +
+
+ +

Field history retention

+
+
+
Keep password history for
+
History entries older than this are pruned on save.
+
+
+ +
+
+
+ +
+ `; + + document.getElementById('trash-retention')?.addEventListener('change', (e) => { + if (pendingVaultSettings) { + pendingVaultSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); + } + }); + + document.getElementById('history-retention')?.addEventListener('change', (e) => { + if (pendingVaultSettings) { + pendingVaultSettings.history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); + } + }); + + document.getElementById('save-retention')?.addEventListener('click', async () => { + if (!pendingVaultSettings) return; + const r = await sendMessage({ type: 'save_vault_settings', settings: pendingVaultSettings }); + if (!r.ok) alert(`Save failed: ${r.error}`); + }); +} + +// Copy retention helpers from settings-vault.ts: +function trashRetentionToValue(r: import('../../shared/types').TrashRetention): string { + if (r.kind === 'forever') return 'forever'; + return `days:${r.value}`; +} + +function valueToTrashRetention(v: string): import('../../shared/types').TrashRetention { + if (v === 'forever') return { kind: 'forever' }; + const m = /^days:(\d+)$/.exec(v); + if (m) return { kind: 'days', value: Number(m[1]) }; + return { kind: 'forever' }; +} + +function historyRetentionToValue(r: import('../../shared/types').HistoryRetention): string { + if (r.kind === 'forever') return 'forever'; + if (r.kind === 'last_n') return `last_n:${r.value}`; + return `days:${r.value}`; +} + +function valueToHistoryRetention(v: string): import('../../shared/types').HistoryRetention { + if (v === 'forever') return { kind: 'forever' }; + const mLast = /^last_n:(\d+)$/.exec(v); + if (mLast) return { kind: 'last_n', value: Number(mLast[1]) }; + const mDays = /^days:(\d+)$/.exec(v); + if (mDays) return { kind: 'days', value: Number(mDays[1]) }; + return { kind: 'forever' }; +} +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): retention section (trash + field history)" +``` + +--- + +### Task 8: Backup section (Vault) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +- [ ] **Step 1: Read current backup content in settings-vault.ts** + +```bash +grep -n "backup\|Backup\|restore\|Restore" extension/src/popup/components/settings-vault.ts | head -20 +``` + +- [ ] **Step 2: Implement `renderBackupSection()`** + +Extract the backup/restore content from settings-vault.ts. Keep the same functionality, just restyled with `setting-row` pattern: + +```ts +function renderBackupSection(content: HTMLElement): void { + content.innerHTML = ` +

Backup

+
+
+
Export backup
+
Download an encrypted backup of your entire vault.
+
+
+ +
+
+
+
+
Restore backup
+
Restore from a previously exported backup file.
+
+
+ + +
+
+ `; + + // Wire the backup export button — same logic as settings-vault.ts + document.getElementById('backup-export-btn')?.addEventListener('click', async () => { + const btn = document.getElementById('backup-export-btn') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'Exporting…'; + const resp = await sendMessage({ type: 'export_backup' }); + btn.disabled = false; + btn.textContent = 'Download backup'; + if (!resp.ok) { alert(`Export failed: ${resp.error}`); return; } + const data = resp.data as { backup_b64: string; filename: string }; + const bytes = Uint8Array.from(atob(data.backup_b64), c => c.charCodeAt(0)); + const blob = new Blob([bytes], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = data.filename; a.click(); + URL.revokeObjectURL(url); + }); + + document.getElementById('backup-restore-btn')?.addEventListener('click', () => { + document.getElementById('backup-file-input')?.click(); + }); + + document.getElementById('backup-file-input')?.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const arrayBuf = await file.arrayBuffer(); + const b64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuf))); + const resp = await sendMessage({ type: 'import_backup', backup_b64: b64 }); + if (resp.ok) { + alert('Backup restored. The extension will reload.'); + window.location.reload(); + } else { + alert(`Restore failed: ${resp.error}`); + } + }); +} +``` + +- [ ] **Step 3: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): backup section (export + restore)" +``` + +--- + +### Task 9: Import section (Vault) + +**Files:** +- Modify: `extension/src/popup/components/settings.ts` + +- [ ] **Step 1: Read current import content in settings-vault.ts** + +```bash +grep -n "import\|Import\|lastpass\|LastPass" extension/src/popup/components/settings-vault.ts | head -20 +``` + +- [ ] **Step 2: Implement `renderImportSection()`** + +```ts +function renderImportSection(content: HTMLElement): void { + content.innerHTML = ` +

Import

+
+
+
Import from LastPass
+
Import a LastPass CSV export file.
+
+
+ + +
+
+
+ `; + + document.getElementById('import-lp-btn')?.addEventListener('click', () => { + document.getElementById('import-lp-input')?.click(); + }); + + document.getElementById('import-lp-input')?.addEventListener('change', async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (!file) return; + const text = await file.text(); + const resultDiv = document.getElementById('import-result')!; + resultDiv.textContent = 'Importing…'; + const resp = await sendMessage({ type: 'import_lastpass', csv: text }); + if (resp.ok) { + const data = resp.data as { imported: number; warnings: string[] }; + resultDiv.innerHTML = ` +

Imported ${data.imported} items.

+ ${data.warnings.length > 0 ? `

${data.warnings.length} warning(s): ${escapeHtml(data.warnings.slice(0,3).join('; '))}${data.warnings.length > 3 ? '…' : ''}

` : ''} + `; + } else { + resultDiv.innerHTML = `

Import failed: ${escapeHtml(resp.error ?? 'unknown')}

`; + } + }); +} +``` + +- [ ] **Step 3: Build and run all tests** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | grep "error TS" +bun run test 2>&1 | tail -15 +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/settings.ts +git commit -m "feat(ext/settings): import section (LastPass CSV)" +``` + +--- + +### Task 10: Cleanup + full build pass + +**Files:** +- Modify: `extension/src/popup/components/settings-vault.ts` + +The old `renderVaultSettings()` exported function was called from popup.ts and vault.ts. Check all call sites. + +- [ ] **Step 1: Find all callers of settings-vault.ts exports** + +```bash +grep -rn "renderVaultSettings\|settings-vault\|settingsVault" extension/src/ | grep -v ".test.ts" | grep -v "__tests__" +``` + +- [ ] **Step 2: Assess whether settings-vault.ts can be deleted** + +If all callers have been migrated to the new settings.ts sections, and no other code imports from it, it can be deleted. If it's still imported somewhere, keep it as a thin stub or migrate the remaining callers first. + +If safe to delete: + +```bash +git rm extension/src/popup/components/settings-vault.ts +git commit -m "refactor(ext/settings): remove settings-vault.ts (content merged into sectioned settings)" +``` + +If still needed (some callers remain), leave it and note the remaining callers in your status update to PM. + +- [ ] **Step 3: Final build check — both targets** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-b/extension && bun run build 2>&1 | tail -10 +bun run build:firefox 2>&1 | tail -10 +``` + +Expected: both build clean. + +- [ ] **Step 4: Run all tests** + +```bash +bun run test 2>&1 | tail -15 +``` + +Expected: all pass. + +- [ ] **Step 5: Open PR** + +```bash +gh pr create --title "feat: settings UX redesign — left-nav sectioned layout (Stream B)" --base main +``` + +- [ ] **Step 6: Post status to PM** + +``` +## STATUS UPDATE — DEV-B +Time: +Task: 10 of 10 +Status: REVIEW-READY +Summary: All 10 tasks complete. PR open. All sections implemented. Build clean. Tests pass. +Next: waiting for PM +``` diff --git a/docs/superpowers/coordination/v0.5.1-dev-c-prompt.md b/docs/superpowers/coordination/v0.5.1-dev-c-prompt.md new file mode 100644 index 0000000..98d8310 --- /dev/null +++ b/docs/superpowers/coordination/v0.5.1-dev-c-prompt.md @@ -0,0 +1,1353 @@ +# Dev C Kickoff Prompt — v0.5.1 Stream C (Recovery QR) + +> **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. + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are a **senior developer** owning Stream C for the Relicario v0.5.1 release. Stream C implements the Recovery QR feature: Rust core + WASM bindings + CLI subcommand + setup wizard redesign + settings-security.ts component. + +**Goal:** Ship `generate_recovery_qr` / `unwrap_recovery_qr` in relicario-core and WASM, a `recovery-qr` CLI subcommand, a redesigned setup wizard (Style C with glyphs), and a three-state security section component for the settings page. + +**Architecture:** Rust core is the canonical implementation (bytes-in/bytes-out). WASM wraps it for the extension. The extension component (`settings-security.ts`) is fully owned by this stream — DEV-B stubs the import. Session storage in WASM is extended to hold `image_secret` alongside `master_key` so QR generation doesn't require re-uploading the reference image. + +**Tech Stack:** Rust, `qrcode` crate (SVG output), `chacha20poly1305`, `argon2`, `unicode-normalization`; TypeScript (vitest), wasm-bindgen. + +--- + +## Setup (do this first) + +```bash +cd /home/alee/Sources/relicario +git fetch +git checkout main +git pull +git worktree add ../relicario.v0.5.1-stream-c -b feature/v0.5.1-stream-c-recovery-qr +cd ../relicario.v0.5.1-stream-c +pwd # should print /home/alee/Sources/relicario.v0.5.1-stream-c +``` + +**ALL subsequent work happens in `/home/alee/Sources/relicario.v0.5.1-stream-c`**. Every subagent prompt MUST begin with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`. + +Today: 2026-05-03. Project rules in `CLAUDE.md` apply. + +## Required reading + +1. `CLAUDE.md` — project rules +2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — spec sections C1–C6 +3. `crates/relicario-core/src/crypto.rs` — existing KDF and AEAD implementation (read the public API) +4. `crates/relicario-wasm/src/session.rs` — current session storage (you will expand this) +5. `crates/relicario-wasm/src/lib.rs` — existing WASM bindings (you will add to these) +6. `extension/src/setup/setup.ts` — current setup wizard (you will redesign this) + +## Execution mode + +Use **subagent-driven-development**. Fresh subagent per task, two-stage review between tasks. Every subagent prompt MUST start with `cd /home/alee/Sources/relicario.v0.5.1-stream-c`. + +## Scope and boundaries + +**In scope:** C1 (recovery_qr.rs), C2 (CLI), C3 (WASM bindings), C4 (settings-security.ts), C5 (setup wizard QR banner), C6 (setup wizard redesign), session.rs expansion. + +**Out of scope:** Stream A and B work. If you find a bug outside your scope, post it via `## QUESTION TO PM`. + +**Hard rules:** +- QR payload bytes must NEVER be written to `chrome.storage`, IndexedDB, git, or the filesystem. Only `recovery_qr_generated_at` (timestamp) is persisted. +- The passphrase must NOT be logged, stored in a non-Zeroizing container, or leaked through error messages. +- `unlock()` change: image_secret must be stored in `SessionData` alongside master_key. +- Don't merge to main. The PM owns merges. + +## Interface contract with DEV-B + +You own `extension/src/popup/components/settings-security.ts`. DEV-B imports it. The agreed export signature: + +```ts +export async function renderSecuritySection( + container: HTMLElement, + sessionHandle: number | null, +): Promise; + +export function teardownSecuritySection(): void; +``` + +DEV-B has a stub. Your Task 9 provides the real implementation. + +## Coordination protocol + +``` +## STATUS UPDATE — DEV-C +Time: +Task: +Status: IN-PROGRESS | BLOCKED | REVIEW-READY +Summary: +Next: +``` + +--- + +## Files + +**Create:** +- `crates/relicario-core/src/recovery_qr.rs` — core implementation +- `crates/relicario-core/tests/recovery_qr.rs` — integration tests +- `extension/src/popup/components/settings-security.ts` — three-state component + +**Modify:** +- `crates/relicario-core/Cargo.toml` — add `qrcode` +- `crates/relicario-core/src/lib.rs` — `pub mod recovery_qr` + re-exports +- `crates/relicario-core/src/error.rs` — add `RecoveryQr` variant +- `crates/relicario-wasm/Cargo.toml` — add `base64` if not present (check first) +- `crates/relicario-wasm/src/session.rs` — expand to `SessionData` +- `crates/relicario-wasm/src/lib.rs` — update `unlock()`, add new bindings +- `crates/relicario-cli/src/main.rs` — add `recovery-qr` subcommand group +- `extension/src/setup/setup.ts` — wizard redesign + Step 5 QR banner + +--- + +### Task 1: Add `qrcode` crate + +**Files:** +- Modify: `crates/relicario-core/Cargo.toml` + +- [ ] **Step 1: Add dependency** + +```toml +# in [dependencies] section of crates/relicario-core/Cargo.toml +qrcode = { version = "0.14", default-features = false } +``` + +- [ ] **Step 2: Verify it compiles** + +```bash +cargo build -p relicario-core 2>&1 | tail -5 +``` + +Expected: no errors (qrcode may show download progress, that's fine). + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/Cargo.toml Cargo.lock +git commit -m "chore(core): add qrcode dependency for recovery QR" +``` + +--- + +### Task 2: `recovery_qr.rs` — core payload generation + +**Files:** +- Create: `crates/relicario-core/src/recovery_qr.rs` + +Binary payload layout (109 bytes): +``` +[magic "RREC" 4B][version 0x01 1B][kdf_salt 32B][wrap_nonce 24B][ciphertext 48B] +``` + +`ciphertext` = `XChaCha20-Poly1305(wrap_key, wrap_nonce, image_secret)` where the AEAD tag is 16B → 32B + 16B = 48B. + +KDF domain separation: +``` +"relicario-recovery-v1\0" || u64_be(byte_len(nfc_passphrase)) || nfc_passphrase +``` +Fed to Argon2id with production params (m=64MiB, t=3, p=4) and a 32-byte `kdf_salt` (OsRng). + +- [ ] **Step 1: Write the file** + +```rust +// crates/relicario-core/src/recovery_qr.rs +use chacha20poly1305::{XChaCha20Poly1305, Key, KeyInit, aead::Aead}; +use rand::RngCore; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; +use crate::{crypto::KdfParams, error::{RelicarioError, Result}}; + +const MAGIC: &[u8; 4] = b"RREC"; +const VERSION: u8 = 0x01; +const PAYLOAD_LEN: usize = 4 + 1 + 32 + 24 + 48; // 109 + +pub struct RecoveryQrPayload { + bytes: [u8; PAYLOAD_LEN], +} + +impl RecoveryQrPayload { + pub fn as_bytes(&self) -> &[u8; PAYLOAD_LEN] { + &self.bytes + } +} + +fn recovery_kdf_input(passphrase: &str) -> Vec { + let nfc: String = passphrase.nfc().collect(); + let nfc_bytes = nfc.as_bytes(); + let prefix = b"relicario-recovery-v1\0"; + let mut input = Vec::with_capacity(prefix.len() + 8 + nfc_bytes.len()); + input.extend_from_slice(prefix); + input.extend_from_slice(&(nfc_bytes.len() as u64).to_be_bytes()); + input.extend_from_slice(nfc_bytes); + input +} + +fn production_params() -> KdfParams { + KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 } +} + +fn derive_wrap_key( + passphrase: &str, + kdf_salt: &[u8; 32], + params: &KdfParams, +) -> Result> { + let input = recovery_kdf_input(passphrase); + crate::crypto::derive_master_key_raw(&input, kdf_salt, params) +} + +pub fn generate_recovery_qr( + passphrase: &str, + image_secret: &[u8; 32], +) -> Result { + generate_recovery_qr_with_params(passphrase, image_secret, &production_params()) +} + +#[doc(hidden)] +pub fn generate_recovery_qr_with_params( + passphrase: &str, + image_secret: &[u8; 32], + params: &KdfParams, +) -> Result { + let mut kdf_salt = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut kdf_salt); + + let mut wrap_nonce = [0u8; 24]; + rand::rngs::OsRng.fill_bytes(&mut wrap_nonce); + + let wrap_key = derive_wrap_key(passphrase, &kdf_salt, params)?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); + let nonce = chacha20poly1305::XNonce::from_slice(&wrap_nonce); + let ciphertext = cipher.encrypt(nonce, image_secret.as_ref()) + .map_err(|_| RelicarioError::RecoveryQr("wrap encrypt failed".into()))?; + + let mut bytes = [0u8; PAYLOAD_LEN]; + let mut pos = 0; + bytes[pos..pos+4].copy_from_slice(MAGIC); pos += 4; + bytes[pos] = VERSION; pos += 1; + bytes[pos..pos+32].copy_from_slice(&kdf_salt); pos += 32; + bytes[pos..pos+24].copy_from_slice(&wrap_nonce); pos += 24; + bytes[pos..pos+48].copy_from_slice(&ciphertext); // 48 = 32 + 16-tag + + Ok(RecoveryQrPayload { bytes }) +} + +pub fn unwrap_recovery_qr( + payload_bytes: &[u8], + passphrase: &str, +) -> Result> { + unwrap_recovery_qr_with_params(payload_bytes, passphrase, &production_params()) +} + +#[doc(hidden)] +pub fn unwrap_recovery_qr_with_params( + payload_bytes: &[u8], + passphrase: &str, + params: &KdfParams, +) -> Result> { + if payload_bytes.len() != PAYLOAD_LEN { + return Err(RelicarioError::RecoveryQr( + format!("payload must be {PAYLOAD_LEN} bytes, got {}", payload_bytes.len()) + )); + } + if &payload_bytes[0..4] != MAGIC { + return Err(RelicarioError::RecoveryQr("bad magic".into())); + } + if payload_bytes[4] != VERSION { + return Err(RelicarioError::RecoveryQr( + format!("unsupported version 0x{:02x}", payload_bytes[4]) + )); + } + let kdf_salt: &[u8; 32] = payload_bytes[5..37].try_into().unwrap(); + let wrap_nonce = &payload_bytes[37..61]; + let ciphertext = &payload_bytes[61..109]; + + let wrap_key = derive_wrap_key(passphrase, kdf_salt, params)?; + let cipher = XChaCha20Poly1305::new(Key::from_slice(wrap_key.as_ref())); + let nonce = chacha20poly1305::XNonce::from_slice(wrap_nonce); + let plaintext = cipher.decrypt(nonce, ciphertext) + .map_err(|_| RelicarioError::Decrypt)?; + + let mut out = Zeroizing::new([0u8; 32]); + out.copy_from_slice(&plaintext); + Ok(out) +} +``` + +- [ ] **Step 2: Add `RecoveryQr` variant to error.rs** + +In `crates/relicario-core/src/error.rs`, add after the `HotpNotSupported` variant: + +```rust + /// Recovery QR generation or parsing failed. + #[error("recovery QR: {0}")] + RecoveryQr(String), +``` + +- [ ] **Step 3: Check if `derive_master_key_raw` exists** + +```bash +grep -n "pub fn derive_master_key" crates/relicario-core/src/crypto.rs +``` + +If only `derive_master_key` exists (taking `passphrase_bytes: &[u8], image_secret: &[u8; 32]`), you need to add a `derive_master_key_raw(input: &[u8], salt: &[u8; 32], params: &KdfParams)` variant. Check the crypto.rs implementation and add it if needed. + +- [ ] **Step 4: Compile** + +```bash +cargo build -p relicario-core 2>&1 | grep -E "error|warning: unused" +``` + +Expected: compiles clean or only pre-existing warnings. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/src/recovery_qr.rs crates/relicario-core/src/error.rs +git commit -m "feat(core): recovery_qr generate + unwrap functions" +``` + +--- + +### Task 3: `recovery_qr_to_svg` + +**Files:** +- Modify: `crates/relicario-core/src/recovery_qr.rs` + +- [ ] **Step 1: Add the SVG function** + +Add to `recovery_qr.rs`: + +```rust +pub fn recovery_qr_to_svg(payload: &RecoveryQrPayload) -> String { + use qrcode::{QrCode, EcLevel}; + let code = QrCode::with_error_correction_level(payload.bytes.as_ref(), EcLevel::M) + .expect("109-byte payload always fits QR version 6"); + let svg_str = code.render::() + .min_dimensions(140, 140) + .build(); + svg_str +} +``` + +- [ ] **Step 2: Compile and check** + +```bash +cargo build -p relicario-core 2>&1 | grep error +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/src/recovery_qr.rs +git commit -m "feat(core): recovery_qr_to_svg renders 140px SVG" +``` + +--- + +### Task 4: Wire into `lib.rs` + +**Files:** +- Modify: `crates/relicario-core/src/lib.rs` + +- [ ] **Step 1: Add module and re-exports** + +Add to `lib.rs` (after the `device` module block): + +```rust +pub mod recovery_qr; +pub use recovery_qr::{ + generate_recovery_qr, generate_recovery_qr_with_params, + recovery_qr_to_svg, + unwrap_recovery_qr, unwrap_recovery_qr_with_params, + RecoveryQrPayload, +}; +``` + +- [ ] **Step 2: Compile** + +```bash +cargo build -p relicario-core 2>&1 | grep error +``` + +- [ ] **Step 3: Commit** + +```bash +git add crates/relicario-core/src/lib.rs +git commit -m "chore(core): re-export recovery_qr module" +``` + +--- + +### Task 5: Integration tests for recovery QR + +**Files:** +- Create: `crates/relicario-core/tests/recovery_qr.rs` + +- [ ] **Step 1: Write failing tests** + +```rust +use relicario_core::{ + crypto::KdfParams, + generate_recovery_qr_with_params, recovery_qr_to_svg, unwrap_recovery_qr_with_params, +}; + +fn fast_params() -> KdfParams { + KdfParams { argon2_m: 256, argon2_t: 1, argon2_p: 1 } +} + +fn test_secret() -> [u8; 32] { + let mut s = [0u8; 32]; + for (i, b) in s.iter_mut().enumerate() { *b = i as u8; } + s +} + +#[test] +fn roundtrip_recovers_image_secret() { + let passphrase = "correct-horse-battery-staple"; + let secret = test_secret(); + let payload = generate_recovery_qr_with_params(passphrase, &secret, &fast_params()) + .expect("generate ok"); + let recovered = unwrap_recovery_qr_with_params(payload.as_bytes(), passphrase, &fast_params()) + .expect("unwrap ok"); + assert_eq!(recovered.as_ref(), &secret); +} + +#[test] +fn wrong_passphrase_fails_decrypt() { + let secret = test_secret(); + let payload = generate_recovery_qr_with_params("right-pass", &secret, &fast_params()) + .expect("generate ok"); + let result = unwrap_recovery_qr_with_params(payload.as_bytes(), "wrong-pass", &fast_params()); + assert!(result.is_err()); +} + +#[test] +fn payload_is_109_bytes() { + let secret = test_secret(); + let payload = generate_recovery_qr_with_params("test", &secret, &fast_params()) + .expect("generate ok"); + assert_eq!(payload.as_bytes().len(), 109); +} + +#[test] +fn svg_output_is_non_empty_xml() { + let secret = test_secret(); + let payload = generate_recovery_qr_with_params("test", &secret, &fast_params()) + .expect("generate ok"); + let svg = recovery_qr_to_svg(&payload); + assert!(svg.contains("&1 | tail -20 +``` + +Expected: compile errors or test panics (module not wired yet or functions not yet fully implemented). + +- [ ] **Step 3: Run after Task 2/3/4 are complete and confirm they pass** + +```bash +cargo test -p relicario-core --test recovery_qr -- --nocapture 2>&1 | tail -20 +``` + +Expected: 5 tests pass. + +- [ ] **Step 4: Run full test suite** + +```bash +cargo test -p relicario-core 2>&1 | tail -10 +``` + +Expected: all tests pass (130+ tests green). + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-core/tests/recovery_qr.rs +git commit -m "test(core): recovery_qr roundtrip + error cases" +``` + +--- + +### Task 6: Expand WASM `session.rs` to store `image_secret` + +**Files:** +- Modify: `crates/relicario-wasm/src/session.rs` + +Current: stores only `Zeroizing<[u8; 32]>` per handle. +New: stores `SessionData { master_key, image_secret }` per handle. + +The public `with(handle, |key| ...)` signature is preserved (passes `&Zeroizing<[u8;32]>` = master_key). A new `with_image_secret(handle, |secret| ...)` is added. + +- [ ] **Step 1: Rewrite session.rs** + +```rust +use std::cell::RefCell; +use std::collections::HashMap; +use zeroize::Zeroizing; + +pub struct SessionData { + pub master_key: Zeroizing<[u8; 32]>, + pub image_secret: Zeroizing<[u8; 32]>, +} + +thread_local! { + static SESSIONS: RefCell> = RefCell::new(HashMap::new()); + static NEXT_HANDLE: RefCell = const { RefCell::new(1) }; +} + +pub fn insert(master_key: Zeroizing<[u8; 32]>, image_secret: Zeroizing<[u8; 32]>) -> u32 { + let handle = NEXT_HANDLE.with(|n| { + let mut n = n.borrow_mut(); + let h = *n; + *n = n.wrapping_add(1); + if *n == 0 { *n = 1; } + h + }); + SESSIONS.with(|s| { + s.borrow_mut().insert(handle, SessionData { master_key, image_secret }); + }); + handle +} + +/// Access the master key for a handle. Preserves original `with` signature for all existing callers. +pub fn with(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.master_key))) +} + +/// Access the image_secret for a handle (used by recovery QR). +pub fn with_image_secret(handle: u32, f: F) -> Option +where + F: FnOnce(&Zeroizing<[u8; 32]>) -> R, +{ + SESSIONS.with(|s| s.borrow().get(&handle).map(|d| f(&d.image_secret))) +} + +pub fn remove(handle: u32) -> bool { + SESSIONS.with(|s| s.borrow_mut().remove(&handle).is_some()) +} + +#[cfg(test)] +pub fn clear() { + SESSIONS.with(|s| s.borrow_mut().clear()); +} +``` + +- [ ] **Step 2: Update `unlock()` in lib.rs to pass image_secret to session::insert** + +In `crates/relicario-wasm/src/lib.rs`, find the `unlock()` function. Currently it extracts `image_secret` and discards it after `derive_master_key`. Change it to also store image_secret: + +```rust +#[wasm_bindgen] +pub fn unlock( + passphrase: &str, + image_bytes: &[u8], + salt: &[u8], + params_json: &str, +) -> Result { + let params: KdfParams = serde_json::from_str(params_json) + .map_err(|e| JsError::new(&format!("params: {e}")))?; + let image_secret = imgsecret::extract(image_bytes) + .map_err(|e| JsError::new(&e.to_string()))?; + let salt_arr: &[u8; 32] = salt.try_into() + .map_err(|_| JsError::new("salt must be exactly 32 bytes"))?; + let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, salt_arr, ¶ms) + .map_err(|e| JsError::new(&e.to_string()))?; + let stored_image_secret = Zeroizing::new(image_secret); + let handle = session::insert(master_key, stored_image_secret); + Ok(SessionHandle(handle)) +} +``` + +Add `use zeroize::Zeroizing;` to lib.rs imports if not already present. + +- [ ] **Step 3: Compile** + +```bash +cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error +``` + +Expected: clean (all existing callers still compile because `session::with` signature is unchanged). + +- [ ] **Step 4: Commit** + +```bash +git add crates/relicario-wasm/src/session.rs crates/relicario-wasm/src/lib.rs +git commit -m "feat(wasm): session stores image_secret for recovery QR generation" +``` + +--- + +### Task 7: WASM bindings — `generate_recovery_qr` + `unwrap_recovery_qr` + +**Files:** +- Modify: `crates/relicario-wasm/src/lib.rs` +- Modify: `crates/relicario-wasm/Cargo.toml` (add `base64` if not present) + +- [ ] **Step 1: Check if base64 is already in wasm Cargo.toml** + +```bash +grep "base64" crates/relicario-wasm/Cargo.toml +``` + +If not present, add it: +```toml +base64 = "0.22" +``` + +- [ ] **Step 2: Add WASM bindings to lib.rs** + +Add at the end of `crates/relicario-wasm/src/lib.rs`: + +```rust +use relicario_core::{generate_recovery_qr, recovery_qr_to_svg, unwrap_recovery_qr}; + +/// Generate a recovery QR SVG for the current session. Returns the SVG string. +/// The passphrase is needed because the QR wraps the image_secret under a +/// passphrase-derived key (separate from the master key). +#[wasm_bindgen] +pub fn wasm_generate_recovery_qr( + handle: &SessionHandle, + passphrase: &str, +) -> Result { + let image_secret = session::with_image_secret(handle.0, |s| *s.as_ref()) + .ok_or_else(|| JsError::new("invalid or locked session handle"))?; + let payload = generate_recovery_qr(passphrase, &image_secret) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(recovery_qr_to_svg(&payload)) +} + +/// Unwrap a recovery QR payload (base64-encoded 109-byte blob) using the passphrase. +/// Returns the raw image_secret bytes (32 bytes). +#[wasm_bindgen] +pub fn wasm_unwrap_recovery_qr( + payload_b64: &str, + passphrase: &str, +) -> Result, JsError> { + use base64::{engine::general_purpose::STANDARD, Engine}; + let bytes = STANDARD.decode(payload_b64) + .map_err(|e| JsError::new(&format!("base64: {e}")))?; + let recovered = unwrap_recovery_qr(&bytes, passphrase) + .map_err(|e| JsError::new(&e.to_string()))?; + Ok(recovered.to_vec()) +} +``` + +- [ ] **Step 3: Compile WASM** + +```bash +cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | grep error +``` + +Expected: clean. + +- [ ] **Step 4: Run WASM tests** + +```bash +cargo test -p relicario-wasm 2>&1 | tail -10 +``` + +Expected: existing 3 tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-wasm/src/lib.rs crates/relicario-wasm/Cargo.toml +git commit -m "feat(wasm): expose generate_recovery_qr and unwrap_recovery_qr bindings" +``` + +--- + +### Task 8: CLI `recovery-qr` subcommand + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` + +- [ ] **Step 1: Add the subcommand group to the clap surface** + +In `main.rs`, find the `Commands` enum and add: + +```rust +/// Recovery QR operations. +RecoveryQr { + #[command(subcommand)] + cmd: RecoveryQrCmd, +}, +``` + +Add a new enum: + +```rust +#[derive(clap::Subcommand)] +enum RecoveryQrCmd { + /// Generate a recovery QR code and display it in the terminal. + Generate, + /// Unwrap a recovery QR payload (base64) and print the image_secret as hex. + Unwrap, +} +``` + +- [ ] **Step 2: Implement the handlers** + +In `main.rs`, add to the command dispatch: + +```rust +Commands::RecoveryQr { cmd } => { + match cmd { + RecoveryQrCmd::Generate => cmd_recovery_qr_generate(&vault_dir)?, + RecoveryQrCmd::Unwrap => cmd_recovery_qr_unwrap()?, + } +} +``` + +Add the handler functions: + +```rust +fn cmd_recovery_qr_generate(vault_dir: &std::path::Path) -> relicario_core::Result<()> { + use relicario_core::{generate_recovery_qr, recovery_qr_to_svg}; + use rpassword::prompt_password; + + // Load KDF params from vault settings to derive master key (not needed for QR, + // but we need to verify the passphrase is correct by attempting unlock first). + // Actually, generate_recovery_qr only needs the passphrase and image_secret. + // We need to: (1) prompt passphrase, (2) load reference image, (3) extract + // image_secret, (4) call generate_recovery_qr, (5) render SVG / ASCII. + + let passphrase = Zeroizing::new( + prompt_password("Enter vault passphrase: ") + .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))? + ); + + let img_path = vault_dir.join("secret.jpg"); + let img_bytes = std::fs::read(&img_path) + .map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("read {}: {e}", img_path.display())))?; + let image_secret = relicario_core::imgsecret::extract(&img_bytes)?; + + let payload = generate_recovery_qr(passphrase.as_str(), &image_secret)?; + let svg = recovery_qr_to_svg(&payload); + + // Try Kitty/iTerm2 inline protocol; fall back to ASCII + let term = std::env::var("TERM").unwrap_or_default(); + let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default(); + if term.contains("kitty") || term_program.contains("iTerm") { + // Render SVG to PNG via kitty protocol (best-effort; fall back if unavailable) + // For now, always use ASCII fallback + print_qr_ascii(&payload); + } else { + print_qr_ascii(&payload); + } + + println!("\nRecovery QR generated. Print or photograph this code and store it securely."); + println!("The QR code has NOT been saved to disk."); + Ok(()) +} + +fn print_qr_ascii(payload: &relicario_core::RecoveryQrPayload) { + use qrcode::{QrCode, EcLevel, render::unicode}; + let code = QrCode::with_error_correction_level(payload.as_bytes().as_ref(), EcLevel::M) + .expect("valid payload"); + let image = code.render::() + .dark_color(unicode::Dense1x2::Dark) + .light_color(unicode::Dense1x2::Light) + .build(); + println!("{}", image); +} + +fn cmd_recovery_qr_unwrap() -> relicario_core::Result<()> { + use relicario_core::unwrap_recovery_qr; + use rpassword::prompt_password; + use std::io::{self, BufRead}; + use base64::{engine::general_purpose::STANDARD, Engine}; + + println!("Paste the base64 recovery QR payload, then press Enter:"); + let stdin = io::stdin(); + let payload_b64 = stdin.lock().lines().next() + .ok_or_else(|| relicario_core::RelicarioError::RecoveryQr("no input".into()))? + .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))?; + let payload_b64 = payload_b64.trim(); + + let bytes = STANDARD.decode(payload_b64) + .map_err(|e| relicario_core::RelicarioError::RecoveryQr(format!("base64: {e}")))?; + + let passphrase = Zeroizing::new( + prompt_password("Enter passphrase: ") + .map_err(|e| relicario_core::RelicarioError::RecoveryQr(e.to_string()))? + ); + + let secret = unwrap_recovery_qr(&bytes, passphrase.as_str())?; + println!("image_secret: {}", hex::encode(secret.as_ref())); + Ok(()) +} +``` + +Add `use zeroize::Zeroizing;` and `use base64::{engine::general_purpose::STANDARD, Engine};` at the top of main.rs if not already present. + +Also add `qrcode`, `base64`, and `hex` to `crates/relicario-cli/Cargo.toml` if not already present (check first with `grep "qrcode\|base64\|hex" crates/relicario-cli/Cargo.toml`). + +- [ ] **Step 3: Compile** + +```bash +cargo build -p relicario-cli 2>&1 | grep error +``` + +- [ ] **Step 4: Smoke test** + +```bash +cargo run -p relicario-cli -- recovery-qr --help +``` + +Expected: shows subcommand help with `generate` and `unwrap` listed. + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/Cargo.toml +git commit -m "feat(cli): recovery-qr generate / unwrap subcommands" +``` + +--- + +### Task 9: Extension — `settings-security.ts` three-state component + +**Files:** +- Create: `extension/src/popup/components/settings-security.ts` + +States: +- **State 1 (no QR):** `chrome.storage.local.recovery_qr_generated_at` is null/undefined. Show amber warning + "Generate recovery QR…" button. +- **State 2 (exists, at rest):** timestamp is set. Show green status + "Show / print QR…" and "Regenerate…" buttons. +- **State 3 (explicit view):** modal overlay with rendered SVG QR, print button, done button. + +- [ ] **Step 1: Write the component** + +```ts +// extension/src/popup/components/settings-security.ts + +import { sendMessage, escapeHtml } from '../../shared/state'; +import type { DeviceEntry } from '../../shared/types'; + +export async function renderSecuritySection( + container: HTMLElement, + sessionHandle: number | null, +): Promise { + const ts = await getQrGeneratedAt(); + renderSecurityContent(container, ts, sessionHandle); +} + +export function teardownSecuritySection(): void { + document.getElementById('relicario-qr-modal')?.remove(); +} + +async function getQrGeneratedAt(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get('recovery_qr_generated_at', (res) => { + resolve(res['recovery_qr_generated_at'] ?? null); + }); + }); +} + +function renderSecurityContent( + container: HTMLElement, + qrGeneratedAt: number | null, + sessionHandle: number | null, +): void { + const dateStr = qrGeneratedAt + ? new Date(qrGeneratedAt).toLocaleDateString(undefined, { dateStyle: 'medium' }) + : null; + + const qrCardHtml = qrGeneratedAt + ? `
+
◉ Recovery QR is set up · ${escapeHtml(dateStr!)}
+
+ + +
+
` + : `
+
▲ No recovery QR — losing your reference image would make this vault unrecoverable.
+
+ +
+
`; + + container.innerHTML = ` +
+
Recovery QR
+ ${qrCardHtml} +
+
+
Trusted Devices
+
Loading…
+
+ `; + + document.getElementById('sec-gen-qr')?.addEventListener('click', () => + handleGenerateQr(container, sessionHandle!, false)); + document.getElementById('sec-show-qr')?.addEventListener('click', () => + handleGenerateQr(container, sessionHandle!, false)); + document.getElementById('sec-regen-qr')?.addEventListener('click', () => { + if (confirm('Regenerate recovery QR? This will overwrite any existing printed QR.')) { + handleGenerateQr(container, sessionHandle!, true); + } + }); + + loadDevices(); +} + +async function handleGenerateQr( + container: HTMLElement, + sessionHandle: number, + isRegen: boolean, +): Promise { + const passphrase = prompt( + isRegen ? 'Enter passphrase to regenerate QR:' : 'Enter passphrase to generate QR:' + ); + if (!passphrase) return; + + try { + const { wasmGenerateRecoveryQr } = await import('../../shared/wasm'); + const svg = await wasmGenerateRecoveryQr(sessionHandle, passphrase); + const now = Date.now(); + await new Promise((resolve) => { + chrome.storage.local.set({ recovery_qr_generated_at: now }, resolve); + }); + showQrModal(svg); + // Re-render the section in state 2 + renderSecurityContent(container, now, sessionHandle); + } catch (err) { + alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`); + } +} + +function showQrModal(svg: string): void { + document.getElementById('relicario-qr-modal')?.remove(); + + const modal = document.createElement('div'); + modal.id = 'relicario-qr-modal'; + modal.className = 'qr-modal-overlay'; + modal.innerHTML = ` +
+
+ Recovery QR + +
+
${svg}
+
▲ Close this window before stepping away. This QR is only displayed, never saved.
+
+ +
+
+ `; + document.body.appendChild(modal); + + document.getElementById('qr-modal-done')?.addEventListener('click', () => modal.remove()); + document.getElementById('qr-modal-print')?.addEventListener('click', () => { + const iframe = document.createElement('iframe'); + iframe.style.cssText = 'position:absolute;width:0;height:0;border:0;'; + document.body.appendChild(iframe); + const doc = iframe.contentWindow!.document; + doc.open(); + doc.write(`${svg}`); + doc.close(); + iframe.contentWindow!.print(); + setTimeout(() => iframe.remove(), 1000); + }); +} + +async function loadDevices(): Promise { + const list = document.getElementById('sec-devices-list'); + if (!list) return; + const resp = await sendMessage({ type: 'list_devices' }); + if (!resp.ok) { + list.innerHTML = `Failed to load devices: ${escapeHtml(resp.error ?? 'unknown')}`; + return; + } + const data = resp.data as { devices: DeviceEntry[] }; + if (data.devices.length === 0) { + list.innerHTML = 'No registered devices.'; + return; + } + list.innerHTML = data.devices.map((d) => ` +
+
+ ${escapeHtml(d.name ?? 'unnamed')} + ${escapeHtml(d.fingerprint ?? '')} +
+ +
+ `).join(''); + + list.querySelectorAll('.device-row__revoke').forEach((btn) => { + btn.addEventListener('click', async () => { + const fp = (btn as HTMLElement).dataset.fp!; + if (!confirm(`Revoke device ${fp.slice(0, 16)}…?`)) return; + const r = await sendMessage({ type: 'revoke_device', fingerprint: fp }); + if (r.ok) { + await renderSecuritySection(list.closest('.settings-section-content') as HTMLElement, null); + } else { + alert(`Revoke failed: ${r.error}`); + } + }); + }); +} +``` + +**Note:** The `wasmGenerateRecoveryQr` import from `../../shared/wasm` — check what the WASM module exports and match the function name. It may be `wasm_generate_recovery_qr` (Rust snake_case) or camelCase depending on wasm-pack. Adjust the import accordingly. Also check that `DeviceEntry` is exported from `../../shared/types`. + +- [ ] **Step 2: Build and check TypeScript errors** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR" +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/popup/components/settings-security.ts +git commit -m "feat(ext/settings): settings-security.ts three-state recovery QR + devices component" +``` + +--- + +### Task 10: Rebuild WASM artifact + +Before the extension builds can link to the new WASM bindings, rebuild the artifact. + +- [ ] **Step 1: Build WASM** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-c +npm run build:wasm --prefix extension +``` + +Or equivalently: +```bash +wasm-pack build crates/relicario-wasm --target web --out-dir ../../extension/wasm +``` + +- [ ] **Step 2: Run extension build** + +```bash +cd extension && bun run build 2>&1 | grep -E "error|ERROR" | head -20 +``` + +Expected: clean (only pre-existing bundle size warnings). + +- [ ] **Step 3: Commit the updated WASM artifact** + +```bash +git add extension/wasm/ +git commit -m "chore(wasm): rebuild artifact with recovery QR bindings" +``` + +--- + +### Task 11: Setup wizard redesign — Style C + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +Style C replaces the current glass-card layout: +- Full-page dark background (`--bg-page`) +- Logo glyph + wordmark centered at top +- **Colored progress track**: horizontal segments, `--success` for completed, `--gold` for current, `--border` for pending +- Centered card (max-width 560px): step eyebrow ("Step N of 5 · name"), h2 heading, hint, form, actions +- Glyphs not emoji. Mode cards use `◈` (create new) and `⌥` (attach), rendered at 28px. +- Action row: "◂ back" left, "Continue ▸" right + +- [ ] **Step 1: Read the current setup.ts structure** + +```bash +wc -l extension/src/setup/setup.ts +grep -n "^function render\|^async function render\|step[0-9]" extension/src/setup/setup.ts | head -30 +``` + +- [ ] **Step 2: Add the progress track + card wrapper helpers** + +At the top of setup.ts (after existing imports), add: + +```ts +const STEP_NAMES = ['vault setup', 'choose mode', 'passphrase', 'reference image', 'done']; + +function renderProgressTrack(current: number): string { + return ` +
+ ${STEP_NAMES.map((name, i) => { + const cls = i < current ? 'completed' : i === current ? 'active' : 'pending'; + return `
`; + }).join('')} +
+ `; +} + +function wrapStepCard(stepIdx: number, heading: string, hint: string, bodyHtml: string, actionsHtml: string): string { + return ` +
+ + ${renderProgressTrack(stepIdx)} +
+
Step ${stepIdx + 1} of 5 · ${STEP_NAMES[stepIdx]}
+

${heading}

+

${hint}

+
+ ${bodyHtml} +
+
+ ${actionsHtml} +
+
+
+ `; +} +``` + +- [ ] **Step 3: Rewrite `renderStep0` (intro) and `renderStep1` (mode selection)** + +`renderStep1` currently shows the "create new / attach existing" mode cards with emoji. Change to `◈` / `⌥` at 28px, wrapped in `wrapStepCard(1, ...)`. + +Find `renderStep1` in setup.ts and replace the mode card HTML: + +```ts +// Old: emoji mode card icons → new: glyph at 28px +// Replace the mode card icon spans with: +// // create new +// // attach existing +``` + +Wrap each `renderStepN` function body with `wrapStepCard(N, heading, hint, bodyHtml, actionsHtml)`. + +- [ ] **Step 4: Add progress track CSS** + +In the setup CSS (either inline in setup.ts or the extracted CSS file — check where the existing setup styles live): + +```css +.setup-progress-track { + display: flex; + gap: 4px; + width: 100%; + max-width: 560px; + margin: 12px auto; +} + +.setup-progress-segment { + flex: 1; + height: 4px; + border-radius: 2px; +} + +.setup-progress-segment--completed { background: var(--success, #238636); } +.setup-progress-segment--active { background: var(--gold, #b8860b); } +.setup-progress-segment--pending { background: var(--border, #30363d); } + +.setup-page { + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; + padding: 40px 20px; + background: var(--bg-page, #0d1117); +} + +.setup-card { + width: 100%; + max-width: 560px; + background: var(--bg-elevated, #161b22); + border: 1px solid var(--border, #30363d); + border-radius: 12px; + padding: 32px; +} + +.setup-card__eyebrow { font-size: 11px; color: var(--text-muted, #8b949e); margin-bottom: 8px; } +.setup-card__heading { font-size: 20px; font-weight: 700; margin: 0 0 8px; } +.setup-card__hint { font-size: 13px; color: var(--text-muted, #8b949e); margin: 0 0 24px; } +.setup-card__actions { display: flex; justify-content: space-between; margin-top: 24px; } + +.setup-logo { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 24px; +} +.setup-logo__img { width: 24px; height: 24px; } +.setup-logo__wordmark { font-size: 18px; font-weight: 700; } +``` + +- [ ] **Step 5: Build and verify** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR" +``` + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat(ext/setup): wizard redesign — Style C card layout, progress track, glyphs" +``` + +--- + +### Task 12: Setup wizard — Step 5 recovery QR banner + +**Files:** +- Modify: `extension/src/setup/setup.ts` + +The final step ("done") adds a skippable banner above the "Download reference image" button. + +- [ ] **Step 1: Find `renderStep5` (or whatever the final step is)** + +```bash +grep -n "renderStep\|step.*5\|done\|finish\|download" extension/src/setup/setup.ts | tail -30 +``` + +- [ ] **Step 2: Add banner HTML to the final step** + +Find the "done" / final-step render function and add the recovery QR banner before the download button: + +```ts +const qrBannerHtml = ` +
+ +
+ Generate a recovery QR before you go +

If you lose your reference image, this QR lets you recover your vault.

+
+
+ + +
+
+`; +``` + +Insert this banner into the step body HTML (before the download button). + +- [ ] **Step 3: Wire the banner buttons** + +In the step's `attachStepN()` wiring function, add: + +```ts +document.getElementById('setup-gen-qr')?.addEventListener('click', async () => { + const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'Generating…'; + const passphrase = (document.getElementById('setup-passphrase') as HTMLInputElement)?.value; + // The passphrase field should still be accessible from state or the wizard's stored value. + // If not, prompt for it: + const finalPassphrase = passphrase || prompt('Enter passphrase to generate QR:') || ''; + if (!finalPassphrase) { btn.disabled = false; btn.textContent = 'Generate now'; return; } + + try { + const { wasmGenerateRecoveryQr } = await import('../shared/wasm'); + // sessionHandle is available from the setup wizard's unlock step + const handle = getSetupSessionHandle(); // replace with actual accessor + const svg = await wasmGenerateRecoveryQr(handle, finalPassphrase); + await new Promise((resolve) => { + chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); + }); + // Show inline QR + const banner = document.getElementById('recovery-qr-banner')!; + banner.classList.add('recovery-qr-banner--generated'); + banner.innerHTML = ` +
${svg}
+
◉ Recovery QR generated — save or print this QR now.
+
+ +
+ `; + document.getElementById('setup-qr-done')?.addEventListener('click', () => { + banner.innerHTML = '◉ Recovery QR generated.'; + }); + } catch (err) { + btn.disabled = false; + btn.textContent = 'Generate now'; + alert(`Failed: ${err instanceof Error ? err.message : String(err)}`); + } +}); + +document.getElementById('setup-skip-qr')?.addEventListener('click', () => { + const banner = document.getElementById('recovery-qr-banner'); + if (banner) banner.style.display = 'none'; +}); +``` + +**Note:** `getSetupSessionHandle()` — you need to check how the wizard stores the session handle after unlock. Look for where `unlock()` is called in setup.ts and where the result is stored. Adjust accordingly. + +- [ ] **Step 4: Add banner CSS** + +```css +.recovery-qr-banner { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--bg-elevated, #161b22); + border: 1px solid var(--gold, #b8860b); + border-radius: 8px; + margin-bottom: 16px; +} + +.recovery-qr-banner__icon { font-size: 20px; } + +.recovery-qr-banner__text p { margin: 4px 0 0; font-size: 12px; color: var(--text-muted, #8b949e); } + +.recovery-qr-banner__actions { display: flex; gap: 8px; margin-top: 8px; } + +.recovery-qr-banner--generated { border-color: var(--success, #238636); } + +.qr-inline svg { display: block; margin: 0 auto; } + +.recovery-qr-banner__ok { font-size: 12px; color: var(--success, #238636); } +``` + +- [ ] **Step 5: Build** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-c/extension && bun run build 2>&1 | grep -E "error TS|ERROR" +``` + +- [ ] **Step 6: Run full test suites** + +```bash +cd /home/alee/Sources/relicario.v0.5.1-stream-c +cargo test 2>&1 | tail -10 +cd extension && bun run test 2>&1 | tail -10 +``` + +Expected: all pass. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/setup/setup.ts +git commit -m "feat(ext/setup): recovery QR banner in final wizard step" +``` + +--- + +## Final steps + +- [ ] Open PR: `gh pr create --title "feat: recovery QR (Stream C)" --base main` +- [ ] Post `## STATUS UPDATE — DEV-C / Action: REVIEW-READY` with PR URL to PM +- [ ] Respond to any PM review comments diff --git a/docs/superpowers/coordination/v0.5.1-pm-prompt.md b/docs/superpowers/coordination/v0.5.1-pm-prompt.md new file mode 100644 index 0000000..6f50844 --- /dev/null +++ b/docs/superpowers/coordination/v0.5.1-pm-prompt.md @@ -0,0 +1,165 @@ +# PM Kickoff Prompt — v0.5.1 UX Polish + Recovery QR + +Paste everything below the `---` line into a fresh Claude Code terminal as the first user message. + +--- + +You are the **project manager** for the Relicario v0.5.1 release. Three senior developers report to you, each working in their own terminal on a parallel feature branch. The user runs all four terminals and relays messages between them. + +## Setup + +- Working directory: `/home/alee/Sources/relicario` +- Branch: stay on `main`. Do not check out feature branches. +- Today: 2026-05-03. Project rules in `CLAUDE.md` apply (Spanish flourish, capitalization, autonomy defaults, never run git-destructive commands without asking). + +## Required reading (in order) + +1. `CLAUDE.md` — project rules +2. `docs/superpowers/specs/2026-05-03-v0.5.x-ux-polish-and-recovery-qr-design.md` — full spec +3. `docs/superpowers/coordination/v0.5.1-dev-a-prompt.md` — Dev A's plan (Stream A: fullscreen + popup layout) +4. `docs/superpowers/coordination/v0.5.1-dev-b-prompt.md` — Dev B's plan (Stream B: settings UX) +5. `docs/superpowers/coordination/v0.5.1-dev-c-prompt.md` — Dev C's plan (Stream C: recovery QR) + +## Your authority + +- Approve or deny scope changes from devs +- Review and merge PRs from all three feature branches +- **Drive the interface contract** between B and C (see below) — this is your first hands-on action +- Write the `CHANGELOG.md` entry for v0.5.1 +- Tag `v0.5.1` once everything is integrated **— but only after explicit user approval** + +## Your boundaries + +- Don't write feature code yourself. Edits to docs / CHANGELOG / CLAUDE.md are fine. +- Don't deviate from the spec without user approval. +- Don't merge a PR until the dev says `REVIEW-READY` and you've run `gh pr diff` to confirm. +- Don't tag without user approval. +- Project rule: ask the user before any git-destructive op. + +## Stream overview + +| Stream | Branch | Owner | Core files | +|--------|--------|-------|-----------| +| A — Fullscreen + popup layout | `feature/v0.5.1-stream-a-layout` | DEV-A | `vault.ts`, `vault.css`, `item-list.ts`, `item-form.ts`, `glyphs.ts`, `toast.ts` | +| B — Settings UX | `feature/v0.5.1-stream-b-settings` | DEV-B | `settings.ts`, `settings-vault.ts` (decomposed), `settings-security.ts` (stub only) | +| C — Recovery QR | `feature/v0.5.1-stream-c-recovery-qr` | DEV-C | `recovery_qr.rs`, WASM `session.rs`/`lib.rs`, `settings-security.ts`, `setup.ts` | + +## Interface contracts (enforce before work starts) + +### A–B: Settings component signature + +DEV-B's settings component is wired into vault.ts by DEV-A. Both must agree before either proceeds with their vault.ts / settings.ts work. + +**Agreed interface** (post to both devs as your first directive): + +```ts +// extension/src/popup/components/settings.ts + +/** + * Render the full sectioned settings view into `container`. + * May be called from vault.ts (fullscreen, full-width pane) or popup.ts (popup). + */ +export async function renderSettings(container: HTMLElement): Promise; + +/** + * Teardown: close any open generator panel, remove keyboard listeners. + * Call before navigating away from the settings view. + */ +export function teardownSettings(): void; +``` + +DEV-A imports `{ renderSettings, teardownSettings }` from `settings.ts` in vault.ts. +DEV-B exports these names with these exact signatures. + +### B–C: Security section component signature + +DEV-C owns and implements `settings-security.ts`. DEV-B imports it for the Security section. They must agree before DEV-B writes B4 (Security section) or DEV-C writes C8 (settings-security.ts). + +**Agreed interface** (post to both devs as your first directive): + +```ts +// extension/src/popup/components/settings-security.ts + +/** + * Render the three-state Recovery QR + trusted devices security section + * into `container`. `sessionHandle` is the current WASM session handle value + * (from the service-worker's session), or null if the vault is locked. + */ +export async function renderSecuritySection( + container: HTMLElement, + sessionHandle: number | null, +): Promise; + +/** + * Teardown: remove any event listeners attached during render. + */ +export function teardownSecuritySection(): void; +``` + +DEV-B stubs this interface in `settings-security.ts` immediately after receiving this directive. DEV-C replaces it with the real implementation. + +## Merge order and strategy + +1. **C lands first** (or concurrently with A; no A or B dependency). Merge once DEV-C posts REVIEW-READY. +2. **A and B can merge in either order** after C is on main, since both will rebase/merge main before PR. +3. No squash merges — git history is preserved per project rule. +4. No force pushes. Each dev opens a PR; PM reviews diff; PM merges with `gh pr merge --merge`. + +## Coordination protocol + +You are one of four terminals. The user relays messages. + +**You receive:** `## STATUS UPDATE — DEV-A/B/C` or `## QUESTION TO PM — DEV-X` blocks. + +**You emit:** a `## DIRECTIVE TO DEV-X` block. Format: + +``` +## DIRECTIVE TO DEV-A (or B or C) +Time: +Action: PROCEED | HOLD | RESCOPE | REVIEW-COMPLETE | MERGE-APPROVED +Notes: +Next: +``` + +When asked "status?" by the user at any time: + +``` +## RELEASE STATUS — v0.5.1 +Dev A: +Dev B: +Dev C: +PM: +Blockers: +Next milestone: +``` + +## Reviewing PRs + +When a dev posts `Action: REVIEW-READY` with a PR URL: +1. `gh pr view ` to read description and CI status +2. `gh pr diff ` to read changes +3. Check diff against the spec sections owned by that stream +4. If green: post `Action: MERGE-APPROVED` and run `gh pr merge --merge` +5. If red: post `Action: HOLD` with specific concerns + +## Pre-tag checklist + +Before tagging v0.5.1: + +- [ ] `feature/v0.5.1-stream-a-layout` merged to main +- [ ] `feature/v0.5.1-stream-b-settings` merged to main +- [ ] `feature/v0.5.1-stream-c-recovery-qr` merged to main +- [ ] `cargo test` green on main +- [ ] `bun run test` green (extension) +- [ ] `cargo build -p relicario-wasm --target wasm32-unknown-unknown` green +- [ ] `bun run build` + `bun run build:firefox` clean (extension) +- [ ] No emoji in any UI surface (grep: `'🔑\|📝\|🪪\|💳\|🗝\|📄\|⏱️'` in `extension/src/`) +- [ ] `GLYPH_VAULT_TAB` in glyphs.ts; no inline `⤴` anywhere +- [ ] `recovery_qr_generated_at` is the only persisted QR artifact (grep: no QR SVG in chrome.storage calls) +- [ ] Settings left-nav sections all render without console errors +- [ ] `CHANGELOG.md` entry for v0.5.1 written +- [ ] Explicit user approval to tag + +## First action + +After reading: post a `## RELEASE STATUS — v0.5.1` block, then post your first directive to all three devs simultaneously — confirming the A–B and B–C interface contracts above. Wait for devs to acknowledge before instructing them to proceed with their task lists.