From 2ca563a8cd511765415adfd6eba757142688fcf9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 24 Apr 2026 00:09:25 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20Plan=201C-=CE=B2=E2=82=82=20(custom=20f?= =?UTF-8?q?ields=20+=20settings=20+=20generator=20UI)=20implementation=20p?= =?UTF-8?q?lan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 tasks across 5 slices + pre-flight + acceptance. Follows α/β₁'s cadence — each task one commit, each step 2-5 minutes, complete code in every step. Slice 1 — Custom-fields detail rendering (Tasks 1-2): renderSections helper + 6-type-module integration. Slice 2 — Custom-fields edit rendering (Tasks 3-4): renderSectionsEditor + wireSectionsEditor + generateFieldId helpers, disclosure integration across all 6 forms, per-type save-shape smoke test. Slice 3 — Vault-settings SW plumbing (Tasks 5-8): tighten VaultSettings TS types; add get/update_vault_settings popup-only messages + router tests; add generate_passphrase if missing; fetch vault_settings on popup unlock. Slice 4 — Generator inline popover (Tasks 9-10): generator-popover component + 7 unit tests; Login gen-btn integration + teardown hook. Slice 5 — Settings view + ⚙ picker (Tasks 11-13): settings-vault component + 5 tests; ⚙ picker → device/vault routes; final lint greps + tag. Expected test delta: 84 → ~121 Vitest. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...2026-04-24-relicario-extension-1c-beta2.md | 2650 +++++++++++++++++ 1 file changed, 2650 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md diff --git a/docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md b/docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md new file mode 100644 index 0000000..0869a81 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md @@ -0,0 +1,2650 @@ +# relicario Extension 1C-β₂ (Custom Fields + Settings + Generator UI) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add custom-fields editing, a full vault-settings view, and an inline generator popover to the relicario browser extension. Completes the β phase of Plan 1C. + +**Architecture:** 5-slice bottom-up. Slice 1 adds read-only `Item.sections` rendering in every type-detail view via a new `fields.ts` helper. Slice 2 adds the collapsible "▸ custom sections & fields" disclosure + add/remove UI in every type form. Slice 3 wires new popup-only messages (`get_vault_settings` / `update_vault_settings`, plus `generate_passphrase` for BIP39) — landing BEFORE the popover so Slice 4's "save as default" action is fully functional the moment it ships. Slice 4 builds the generator popover and wires it to every "gen" button. Slice 5 builds the Settings screen, wires the ⚙ picker, and connects the popover to Settings via the save-as-default action. + +**Tech Stack:** TypeScript (extension popup), Vitest + happy-dom, Bun (package manager). No Rust changes. + +**Reference spec:** `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta2-design.md` (commit `62112f5`) + +**Branch:** Create `feature/typed-items-1c-beta2` off `main` (β₁ merged at `81fbe13`, tag `plan-1c-beta1-complete`). + +--- + +## Pre-flight + +- [ ] **P1: Verify main is clean and tests are green** + +```bash +cd /home/alee/Sources/relicario +git status +git checkout main && git pull +cargo test --workspace 2>&1 | grep "test result" | head -3 +``` + +Expected: working tree clean, on `main`, all Rust tests pass (155 from β₁). + +- [ ] **P2: Create the feature worktree** + +```bash +cd /home/alee/Sources/relicario +git worktree add .worktrees/typed-items-1c-beta2 -b feature/typed-items-1c-beta2 +cd .worktrees/typed-items-1c-beta2/extension +bun install +bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: 84 Vitest tests pass (β₁ baseline). + +--- + +## Slice 1 — Custom-fields detail rendering + +### Task 1: Add `renderSections` helper + tests + +**Files:** +- Modify: `extension/src/popup/components/fields.ts` +- Create: `extension/src/popup/components/__tests__/sections-render.test.ts` +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Write the failing tests** + +Create `extension/src/popup/components/__tests__/sections-render.test.ts`: + +```ts +import { describe, expect, it } from 'vitest'; +import { renderSections } from '../fields'; +import type { Item } from '../../../shared/types'; + +function itemWithSections(sections: Item['sections']): Item { + return { + id: 'aaaaaaaaaaaaaaaa', + title: 'test', + type: 'login', + tags: [], favorite: false, + created: 0, modified: 0, + core: { type: 'login' }, + sections, + attachments: [], + field_history: {}, + }; +} + +describe('renderSections', () => { + it('returns empty string when item has no sections', () => { + const html = renderSections(itemWithSections([]), 'login'); + expect(html).toBe(''); + }); + + it('skips sections with zero fields', () => { + const html = renderSections(itemWithSections([ + { name: 'empty', fields: [] }, + ]), 'login'); + expect(html).not.toContain('empty'); + }); + + it('renders a named section header + field rows', () => { + const html = renderSections(itemWithSections([ + { + name: 'recovery codes', + fields: [ + { id: 'f0000001', label: 'code 1', kind: 'text', + value: { kind: 'text', value: 'abc-123' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('recovery codes'); + expect(html).toContain('code 1'); + expect(html).toContain('abc-123'); + }); + + it('renders concealed password fields with unique ids', () => { + const html = renderSections(itemWithSections([ + { + name: 'backup', + fields: [ + { id: 'f0000002', label: 'pin', kind: 'password', + value: { kind: 'password', value: 'hunter2' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('data-revealed="false"'); + // Plaintext should be in data-field-value, not visible + expect(html).not.toMatch(/>hunter2 { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000003', label: 'extra', kind: 'text', + value: { kind: 'text', value: 'note' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).toContain('section-separator'); + expect(html).not.toContain('section-header'); + }); + + it('silently skips unsupported field kinds', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000004', label: 'link', kind: 'url' as any, + value: { kind: 'url', value: 'https://example.com' } as any, + hidden_by_default: false }, + { id: 'f0000005', label: 'note', kind: 'text', + value: { kind: 'text', value: 'kept' }, hidden_by_default: false }, + ], + }, + ]), 'login'); + expect(html).not.toContain('https://example.com'); + expect(html).toContain('kept'); + }); + + it('renders concealed fields for the concealed kind too', () => { + const html = renderSections(itemWithSections([ + { + fields: [ + { id: 'f0000006', label: 'secret', kind: 'concealed', + value: { kind: 'concealed', value: 'shhh' }, hidden_by_default: true }, + ], + }, + ]), 'login'); + expect(html).toContain('data-field-id="login-s0-f0"'); + expect(html).toContain('secret'); + expect(html).not.toMatch(/>shhh&1 | tail -10 +``` + +Expected: import error for `renderSections` from `../fields`. + +- [ ] **Step 3: Add `renderSections` to `fields.ts`** + +Append to `extension/src/popup/components/fields.ts` (after the existing exports): + +```ts +import type { Item, Section } from '../../shared/types'; + +/// Render an Item's sections as read-only field rows. Each section with +/// ≥1 field emits a header (if named) or thin separator (if anonymous) +/// plus field rows via renderRow / renderConcealedRow. Sections with +/// 0 fields are skipped. Fields with unsupported kinds are silently +/// skipped (β₂ supports text, password, concealed only). +/// +/// `idPrefix` uniquifies concealed-row IDs (`${idPrefix}-s{i}-f{j}`) +/// so multiple typed-item detail views rendered in sequence don't +/// collide on wireFieldHandlers lookups. +export function renderSections(item: Item, idPrefix: string): string { + let out = ''; + item.sections.forEach((section, sIdx) => { + const visibleFields = section.fields.filter( + (f) => f.value.kind === 'text' || f.value.kind === 'password' || f.value.kind === 'concealed', + ); + if (visibleFields.length === 0) return; + + if (section.name) { + out += `
${escapeHtml(section.name)}
`; + } else { + out += `
`; + } + + visibleFields.forEach((field, fIdx) => { + if (field.value.kind === 'text') { + out += renderRow({ label: field.label, value: field.value.value, copyable: true }); + } else { + // password or concealed + out += renderConcealedRow({ + id: `${idPrefix}-s${sIdx}-f${fIdx}`, + label: field.label, + value: field.value.value, + }); + } + }); + }); + return out; +} +``` + +- [ ] **Step 4: Add the supporting CSS** + +Append to `extension/src/popup/styles.css`: + +```css +/* --- custom-section rendering (β₂ slice 1) --- */ +.section-header { + margin-top: 14px; + margin-bottom: 4px; + padding-top: 10px; + border-top: 1px solid #21262d; + color: #8b949e; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.section-separator { + margin: 10px 0 4px; + border: 0; + border-top: 1px solid #21262d; +} +``` + +- [ ] **Step 5: Re-run tests — should pass** + +```bash +cd extension && bun run test src/popup/components/__tests__/sections-render.test.ts 2>&1 | tail -10 +``` + +Expected: 7 tests pass. + +- [ ] **Step 6: Verify full test suite + build** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +cd extension && bun run build 2>&1 | tail -3 +``` + +Expected: 91 tests pass (84 baseline + 7 new); build clean. + +- [ ] **Step 7: Commit** + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2 +git add extension/src/popup/components/fields.ts \ + extension/src/popup/components/__tests__/sections-render.test.ts \ + extension/src/popup/styles.css +git commit -m "feat(ext/popup): renderSections helper for custom-field detail rendering" +``` + +### Task 2: Integrate `renderSections` into all 6 type detail views + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts` +- Modify: `extension/src/popup/components/types/secure-note.ts` +- Modify: `extension/src/popup/components/types/identity.ts` +- Modify: `extension/src/popup/components/types/card.ts` +- Modify: `extension/src/popup/components/types/key.ts` +- Modify: `extension/src/popup/components/types/totp.ts` + +- [ ] **Step 1: Add the import to each type module** + +In each of the 6 files, find the existing `fields` import (something like `import { renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers } from '../fields';`) and add `renderSections` to it: + +```ts +import { + renderRow, renderConcealedRow, renderSignatureBlock, + wireFieldHandlers, renderSections, +} from '../fields'; +``` + +- [ ] **Step 2: Insert the `renderSections` call into each `renderDetail`** + +In each type module's `renderDetail` function, the HTML template has a structure like: + +```ts +app.innerHTML = ` +
+ ${/* signature block */} + ${/* typed rows (renderRow / renderConcealedRow) */} +
+ + ... +
+
+`; +``` + +Insert `${renderSections(item, '')}` immediately before the `
`: + +- `login.ts`: `${renderSections(item, 'login')}` before form-actions. +- `secure-note.ts`: `${renderSections(item, 'secure-note')}` before form-actions. +- `identity.ts`: `${renderSections(item, 'identity')}` before form-actions. +- `card.ts`: `${renderSections(item, 'card')}` before form-actions. +- `key.ts`: `${renderSections(item, 'key')}` before form-actions. +- `totp.ts`: `${renderSections(item, 'totp')}` before form-actions. + +Each module's `wireFieldHandlers(app)` call (already present at the bottom of each `renderDetail`) picks up the new rows automatically. + +- [ ] **Step 3: Verify build + tests still pass** + +```bash +cd extension && bun run build 2>&1 | tail -3 +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: build clean; 91 tests pass (unchanged — no new tests, just integration). + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/types/*.ts +git commit -m "feat(ext/popup): render custom sections in all 6 type detail views" +``` + +--- + +## Slice 2 — Custom-fields edit rendering + +### Task 3: Add `renderSectionsEditor` + `generateFieldId` helpers + tests + +**Files:** +- Modify: `extension/src/popup/components/fields.ts` +- Modify: `extension/src/popup/components/__tests__/sections-render.test.ts` (or add a new `sections-editor.test.ts`) +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Write the failing tests** + +Create `extension/src/popup/components/__tests__/sections-editor.test.ts`: + +```ts +import { describe, expect, it, vi } from 'vitest'; +import { renderSectionsEditor, generateFieldId, wireSectionsEditor } from '../fields'; +import type { Section } from '../../../shared/types'; + +describe('generateFieldId', () => { + it('returns 16 hex chars', () => { + const id = generateFieldId(); + expect(id).toMatch(/^[0-9a-f]{16}$/); + }); + it('returns unique values on successive calls', () => { + const ids = new Set(Array.from({ length: 50 }, () => generateFieldId())); + expect(ids.size).toBe(50); + }); +}); + +describe('renderSectionsEditor', () => { + it('shows the disclosure toggle with the correct count', () => { + const sections: Section[] = [ + { name: 'a', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + { id: 'f1', label: 'l', kind: 'password', value: { kind: 'password', value: 'p' }, hidden_by_default: true }, + ] }, + { fields: [ + { id: 'f2', label: 'l', kind: 'concealed', value: { kind: 'concealed', value: 'c' }, hidden_by_default: true }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('2 sections'); + expect(html).toContain('3 fields'); + expect(html).toContain('data-expanded="false"'); + }); + + it('shows singular "1 section / 1 field" when applicable', () => { + const sections: Section[] = [ + { name: 'only', fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'v' }, hidden_by_default: false }, + ] }, + ]; + const html = renderSectionsEditor(sections, false); + expect(html).toContain('1 section'); + expect(html).toContain('1 field'); + expect(html).not.toContain('1 sections'); + expect(html).not.toContain('1 fields'); + }); + + it('renders expanded body when expanded=true', () => { + const html = renderSectionsEditor([], true); + expect(html).toContain('data-expanded="true"'); + expect(html).toContain('add section'); + }); +}); + +describe('wireSectionsEditor', () => { + it('toggle click flips data-expanded', () => { + document.body.innerHTML = renderSectionsEditor([], false); + const sections: Section[] = []; + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const toggle = document.querySelector('.disclosure__toggle') as HTMLButtonElement; + toggle.click(); + const disclosure = document.querySelector('.disclosure') as HTMLElement; + expect(disclosure.getAttribute('data-expanded')).toBe('true'); + }); + + it('add-section click appends an empty section', () => { + const sections: Section[] = []; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addBtn = document.querySelector('.add-section') as HTMLButtonElement; + addBtn.click(); + expect(sections).toHaveLength(1); + expect(sections[0]).toEqual({ name: undefined, fields: [] }); + expect(rerender).toHaveBeenCalled(); + }); + + it('add-text-field click on a section pushes a text field', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const addText = document.querySelector('[data-add-field="text"][data-section-idx="0"]') as HTMLButtonElement; + addText.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].kind).toBe('text'); + expect(sections[0].fields[0].value.kind).toBe('text'); + expect(sections[0].fields[0].value.value).toBe(''); + expect(sections[0].fields[0].hidden_by_default).toBe(false); + expect(sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/); + }); + + it('add-password-field sets hidden_by_default=true', () => { + const sections: Section[] = [{ name: undefined, fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-add-field="password"][data-section-idx="0"]') as HTMLButtonElement).click(); + expect(sections[0].fields[0].hidden_by_default).toBe(true); + expect(sections[0].fields[0].kind).toBe('password'); + }); + + it('remove-field button splices field', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'a', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + { id: 'f1', label: 'b', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const deleteBtn = document.querySelector('[data-delete-field="0-0"]') as HTMLButtonElement; + deleteBtn.click(); + expect(sections[0].fields).toHaveLength(1); + expect(sections[0].fields[0].id).toBe('f1'); + }); + + it('remove-section button splices section (after confirm)', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const sections: Section[] = [ + { name: 'to-remove', fields: [] }, + { name: 'keep', fields: [] }, + ]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + expect(sections[0].name).toBe('keep'); + confirmSpy.mockRestore(); + }); + + it('remove-section cancelled confirm leaves section intact', () => { + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const sections: Section[] = [{ name: 'stays', fields: [] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + (document.querySelector('[data-remove-section="0"]') as HTMLButtonElement).click(); + expect(sections).toHaveLength(1); + confirmSpy.mockRestore(); + }); + + it('label input change mutates section field label in place (no rerender)', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'old', kind: 'text', value: { kind: 'text', value: '' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + const rerender = vi.fn(); + wireSectionsEditor(document.body, sections, rerender); + const labelInput = document.querySelector('[data-field-label="0-0"]') as HTMLInputElement; + labelInput.value = 'new'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].label).toBe('new'); + // rerender NOT called on input (would steal focus) + expect(rerender).not.toHaveBeenCalled(); + }); + + it('value input change mutates section field value in place', () => { + const sections: Section[] = [{ name: undefined, fields: [ + { id: 'f0', label: 'l', kind: 'text', value: { kind: 'text', value: 'old' }, hidden_by_default: false }, + ] }]; + document.body.innerHTML = renderSectionsEditor(sections, true); + wireSectionsEditor(document.body, sections, vi.fn()); + const valueInput = document.querySelector('[data-field-value-input="0-0"]') as HTMLInputElement; + valueInput.value = 'new'; + valueInput.dispatchEvent(new Event('input', { bubbles: true })); + expect(sections[0].fields[0].value).toEqual({ kind: 'text', value: 'new' }); + }); +}); +``` + +- [ ] **Step 2: Run — tests fail** + +```bash +cd extension && bun run test src/popup/components/__tests__/sections-editor.test.ts 2>&1 | tail -10 +``` + +Expected: import errors for `renderSectionsEditor`, `generateFieldId`, `wireSectionsEditor`. + +- [ ] **Step 3: Add the helpers to `fields.ts`** + +Append to `extension/src/popup/components/fields.ts`: + +```ts +import type { Field, FieldValue } from '../../shared/types'; + +/// 16-char hex FieldId. crypto.getRandomValues for 8 bytes. +export function generateFieldId(): string { + const bytes = new Uint8Array(8); + crypto.getRandomValues(bytes); + return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function makeField(kind: 'text' | 'password' | 'concealed'): Field { + const value: FieldValue = { kind, value: '' }; + return { + id: generateFieldId(), + label: 'new field', + kind, + value, + hidden_by_default: kind !== 'text', + }; +} + +/// Render the collapsible custom-sections editor. Returns HTML for the +/// disclosure toggle + body. The expanded-state is owned externally +/// (via a module-scope flag in the caller); this helper reads it as +/// the `expanded` parameter. +export function renderSectionsEditor(sections: Section[], expanded: boolean): string { + const sectionCount = sections.length; + const fieldCount = sections.reduce((sum, s) => sum + s.fields.length, 0); + const sectionLabel = sectionCount === 1 ? '1 section' : `${sectionCount} sections`; + const fieldLabel = fieldCount === 1 ? '1 field' : `${fieldCount} fields`; + const summary = sectionCount === 0 && fieldCount === 0 + ? 'no custom fields' + : `${sectionLabel}, ${fieldLabel}`; + + const body = sections.map((section, sIdx) => renderSectionBlock(section, sIdx)).join(''); + + return ` +
+ +
+ ${body} + +
+
+ `; +} + +function renderSectionBlock(section: Section, sIdx: number): string { + const nameDisplay = section.name + ? `${escapeHtml(section.name)}` + : `(anonymous)`; + + const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join(''); + + return ` +
+
+ ${nameDisplay} + + + + +
+ ${fieldsHtml} +
+ + + +
+
+ `; +} + +function renderEditorField(field: Field, sIdx: number, fIdx: number): string { + const valueStr = (field.value.kind === 'text' || field.value.kind === 'password' || field.value.kind === 'concealed') + ? field.value.value + : ''; + const inputType = field.kind === 'text' ? 'text' : 'password'; + const key = `${sIdx}-${fIdx}`; + return ` +
+ + + +
+ `; +} + +/// Wire click + input handlers on a rendered sections-editor. Mutations +/// happen in place on `sectionsDraft`. `rerender` is called after any +/// structural change (add/remove) to regenerate the disclosure body; +/// label/value edits do NOT trigger rerender (would steal focus). +export function wireSectionsEditor( + scope: HTMLElement, + sectionsDraft: Section[], + rerender: () => void, +): void { + // Disclosure toggle + const toggle = scope.querySelector('.disclosure__toggle') as HTMLButtonElement | null; + const disclosure = scope.querySelector('.disclosure') as HTMLElement | null; + toggle?.addEventListener('click', () => { + if (!disclosure) return; + const expanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.setAttribute('data-expanded', expanded ? 'false' : 'true'); + }); + + // Add section + scope.querySelector('.add-section')?.addEventListener('click', () => { + sectionsDraft.push({ name: undefined, fields: [] }); + rerender(); + }); + + // Rename section + scope.querySelectorAll('[data-rename-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.renameSection); + const current = sectionsDraft[sIdx]?.name ?? ''; + const name = window.prompt('Section name (empty for none):', current); + if (name === null) return; // user cancelled + const trimmed = name.trim(); + sectionsDraft[sIdx].name = trimmed || undefined; + rerender(); + }); + }); + + // Remove section + scope.querySelectorAll('[data-remove-section]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.removeSection); + const name = sectionsDraft[sIdx]?.name ?? '(anonymous)'; + if (!window.confirm(`Remove section "${name}" and all its fields?`)) return; + sectionsDraft.splice(sIdx, 1); + rerender(); + }); + }); + + // Add field + scope.querySelectorAll('[data-add-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const sIdx = Number(btn.dataset.sectionIdx); + const kind = btn.dataset.addField as 'text' | 'password' | 'concealed'; + sectionsDraft[sIdx].fields.push(makeField(kind)); + rerender(); + }); + }); + + // Delete field + scope.querySelectorAll('[data-delete-field]').forEach((btn) => { + btn.addEventListener('click', () => { + const [sIdxStr, fIdxStr] = (btn.dataset.deleteField ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + sectionsDraft[sIdx].fields.splice(fIdx, 1); + rerender(); + }); + }); + + // Label + value inputs (mutate in place, no rerender) + scope.querySelectorAll('[data-field-label]').forEach((input) => { + input.addEventListener('input', () => { + const [sIdxStr, fIdxStr] = (input.dataset.fieldLabel ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + if (sectionsDraft[sIdx]?.fields[fIdx]) { + sectionsDraft[sIdx].fields[fIdx].label = input.value; + } + }); + }); + + scope.querySelectorAll('[data-field-value-input]').forEach((input) => { + input.addEventListener('input', () => { + const [sIdxStr, fIdxStr] = (input.dataset.fieldValueInput ?? '0-0').split('-'); + const sIdx = Number(sIdxStr); + const fIdx = Number(fIdxStr); + const field = sectionsDraft[sIdx]?.fields[fIdx]; + if (!field) return; + // FieldValue is tagged; mutate the `value` field while preserving `kind`. + const kind = field.value.kind as 'text' | 'password' | 'concealed'; + field.value = { kind, value: input.value }; + }); + }); +} +``` + +- [ ] **Step 4: Add CSS** + +Append to `extension/src/popup/styles.css`: + +```css +/* --- custom-section editor (β₂ slice 2) --- */ +.disclosure { + border-top: 1px solid #21262d; + margin-top: 14px; + padding-top: 10px; +} +.disclosure__toggle { + background: transparent; border: 0; color: #58a6ff; + cursor: pointer; font-size: 12px; padding: 0; + font-family: inherit; +} +.disclosure[data-expanded="false"] .disclosure__body { display: none; } + +.section-editor__head { + display: flex; align-items: baseline; gap: 8px; + margin-top: 10px; margin-bottom: 4px; + font-size: 11px; +} +.section-editor__head .name { color: #c9d1d9; font-weight: 600; } +.section-editor__head .name.anon { color: #8b949e; font-style: italic; font-weight: normal; } +.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; } +.section-editor__head .actions button { + background: transparent; border: 0; color: inherit; + cursor: pointer; padding: 0; margin-left: 8px; + font: inherit; +} +.section-editor__head .actions button:hover { color: #c9d1d9; } + +.section-editor__field { + display: grid; grid-template-columns: 120px 1fr auto; + gap: 4px; margin-bottom: 4px; font-size: 11px; +} +.section-editor__field input { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px; +} +.section-editor__field .delete-field { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 14px; padding: 0 4px; +} + +.section-editor__add { + display: flex; gap: 6px; margin-top: 6px; +} +.section-editor__add button { + background: transparent; border: 1px solid #30363d; color: #8b949e; + padding: 2px 10px; border-radius: 3px; cursor: pointer; + font-size: 10px; font-family: inherit; +} +.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; } + +.disclosure__body .add-section { + margin-top: 12px; background: transparent; + border: 1px dashed #30363d; color: #8b949e; + padding: 6px 10px; border-radius: 4px; cursor: pointer; + width: 100%; font-size: 11px; font-family: inherit; +} +.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; } +``` + +- [ ] **Step 5: Re-run tests — should pass** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: 91 + ~13 = 104 tests pass. + +- [ ] **Step 6: Commit** + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2 +git add extension/src/popup/components/fields.ts \ + extension/src/popup/components/__tests__/sections-editor.test.ts \ + extension/src/popup/styles.css +git commit -m "feat(ext/popup): renderSectionsEditor + wireSectionsEditor helpers" +``` + +### Task 4: Integrate section editor into all 6 type forms + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts` +- Modify: `extension/src/popup/components/types/secure-note.ts` +- Modify: `extension/src/popup/components/types/identity.ts` +- Modify: `extension/src/popup/components/types/card.ts` +- Modify: `extension/src/popup/components/types/key.ts` +- Modify: `extension/src/popup/components/types/totp.ts` + +Each of the 6 type modules gets 5 consistent edits. Apply the same pattern to each file: + +- [ ] **Step 1: Add imports + module-scope state to each type module** + +For each file, extend the `fields` import to include `renderSectionsEditor`, `wireSectionsEditor`: + +```ts +import { + renderRow, renderConcealedRow, renderSignatureBlock, + wireFieldHandlers, renderSections, + renderSectionsEditor, wireSectionsEditor, +} from '../fields'; +import type { Section } from '../../../shared/types'; +``` + +Add module-scope state near the existing `activeKeyHandler`: + +```ts +let sectionsExpanded = false; +``` + +Extend `teardown()` to reset the expanded flag: + +```ts +export function teardown(): void { + // ... existing cleanup ... + sectionsExpanded = false; +} +``` + +- [ ] **Step 2: Initialize the draft in each `renderForm`** + +Near the top of each `renderForm`, after reading `existing`: + +```ts +const sectionsDraft: Section[] = existing + ? JSON.parse(JSON.stringify(existing.sections)) as Section[] + : []; +``` + +- [ ] **Step 3: Insert `renderSectionsEditor` + wire call into each form** + +Find the place in each form's HTML template just before the `
` row. Insert: + +```ts +${renderSectionsEditor(sectionsDraft, sectionsExpanded)} +``` + +After `app.innerHTML = ...`, add a `rerender` closure and call `wireSectionsEditor`: + +```ts +const rerender = (): void => { + const disclosure = app.querySelector('.disclosure'); + if (!disclosure) return; + // Preserve expanded state across rerender. + sectionsExpanded = disclosure.getAttribute('data-expanded') === 'true'; + disclosure.outerHTML = renderSectionsEditor(sectionsDraft, sectionsExpanded); + wireSectionsEditor(app, sectionsDraft, rerender); +}; +wireSectionsEditor(app, sectionsDraft, rerender); +``` + +- [ ] **Step 4: Wire the save path** + +Find each module's `save*` function (`saveLogin`, `saveSecureNote`, etc.) and locate the `Item` construction: + +```ts +const item: Item = { + // ... + sections: existing?.sections ?? [], // ← replace this line + // ... +}; +``` + +Replace `sections: existing?.sections ?? []` with `sections: sectionsDraft`. Since `sectionsDraft` is deep-cloned from `existing.sections` (or empty for add), mutations through the editor don't affect `existing`. + +- [ ] **Step 5: Verify build + tests** + +```bash +cd extension && bun run build 2>&1 | tail -3 +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: build clean; 104 tests still pass (no new tests yet — save-shape tests for the editor piggyback on existing per-type save-shape tests in the next step). + +- [ ] **Step 6: Add a per-type save-shape smoke test** + +Create `extension/src/popup/components/types/__tests__/sections-save.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: 'login', + vaultSettings: null, generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +import { renderForm } from '../login'; +import { sendMessage } from '../../../popup'; + +describe('Login form packs sectionsDraft into Item.sections', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } }); + }); + + it('persists added sections and fields', async () => { + const app = document.getElementById('app')!; + renderForm(app, 'add', null); + + (document.getElementById('f-title') as HTMLInputElement).value = 'Example'; + + // Open disclosure + add section + add text field + (document.querySelector('.disclosure__toggle') as HTMLButtonElement).click(); + (document.querySelector('.add-section') as HTMLButtonElement).click(); + (document.querySelector('[data-add-field="text"]') as HTMLButtonElement).click(); + + // Populate the new field's label + value + const labelInput = document.querySelector('[data-field-label="0-0"]') as HTMLInputElement; + labelInput.value = 'recovery email'; + labelInput.dispatchEvent(new Event('input', { bubbles: true })); + const valueInput = document.querySelector('[data-field-value-input="0-0"]') as HTMLInputElement; + valueInput.value = 'backup@example.com'; + valueInput.dispatchEvent(new Event('input', { bubbles: true })); + + document.getElementById('save-btn')!.click(); + await new Promise((r) => setTimeout(r, 5)); + + const calls = vi.mocked(sendMessage).mock.calls; + const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item'); + const msg = addCall![0] as { type: 'add_item'; item: any }; + expect(msg.item.sections).toHaveLength(1); + expect(msg.item.sections[0].fields).toHaveLength(1); + expect(msg.item.sections[0].fields[0].label).toBe('recovery email'); + expect(msg.item.sections[0].fields[0].value).toEqual({ kind: 'text', value: 'backup@example.com' }); + expect(msg.item.sections[0].fields[0].id).toMatch(/^[0-9a-f]{16}$/); + }); +}); +``` + +- [ ] **Step 7: Run + commit** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +cd extension && bun run build 2>&1 | tail -3 +``` + +Expected: 104 + 1 = 105 tests; build clean. + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2 +git add extension/src/popup/components/types/*.ts \ + extension/src/popup/components/types/__tests__/sections-save.test.ts +git commit -m "feat(ext/popup): wire custom-field editor into all 6 type forms" +``` + +--- + +## Slice 3 — Vault-settings SW plumbing + +### Task 5: Tighten `VaultSettings` TS types + +**Files:** +- Modify: `extension/src/shared/types.ts` + +- [ ] **Step 1: Replace the opaque VaultSettings definition** + +Find the existing `VaultSettings` interface in `shared/types.ts`: + +```ts +export interface VaultSettings { + trash_retention: unknown; + field_history_retention: unknown; + generator_defaults: unknown; + attachment_caps: unknown; + autofill_origin_acks: Record; +} +``` + +Replace with tightened types (keeping `attachment_caps` opaque since γ owns it): + +```ts +export type TrashRetention = + | { kind: 'forever' } + | { kind: 'days'; value: number }; + +export type HistoryRetention = + | { kind: 'forever' } + | { kind: 'last_n'; value: number } + | { kind: 'days'; value: number }; + +export interface VaultSettings { + trash_retention: TrashRetention; + field_history_retention: HistoryRetention; + generator_defaults: GeneratorRequest; + attachment_caps: unknown; // opaque — γ tightens + autofill_origin_acks: Record; +} +``` + +Note: `GeneratorRequest` is already exported from this file. `attachment_caps` stays `unknown` — γ will refine. + +- [ ] **Step 2: Verify build** + +```bash +cd extension && bun run build 2>&1 | tail -3 +``` + +Expected: clean. No caller tightens consumption of `trash_retention` / `field_history_retention` / `generator_defaults` yet — all downstream reads happen in β₂ slices 3–5 which add those consumers. + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/shared/types.ts +git commit -m "feat(ext/shared): tighten VaultSettings types for retention + generator_defaults" +``` + +### Task 6: Add `get_vault_settings` + `update_vault_settings` messages + handlers + router tests + +**Files:** +- Modify: `extension/src/shared/messages.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` + +- [ ] **Step 1: Add message types** + +In `extension/src/shared/messages.ts`, add to the `PopupMessage` union: + +```ts + | { type: 'get_vault_settings' } + | { type: 'update_vault_settings'; settings: VaultSettings } +``` + +(Import `VaultSettings` from `./types` at the top if not already imported.) + +Add both types to the `POPUP_ONLY_TYPES` `Set`: + +```ts +const POPUP_ONLY: Array = [ + // ... existing ... + 'get_vault_settings', + 'update_vault_settings', +]; +export const POPUP_ONLY_TYPES: ReadonlySet = new Set(POPUP_ONLY); +``` + +Do NOT add them to `SETUP_ALLOWED`. + +Add a response helper at the bottom of the file: + +```ts +export interface VaultSettingsResponse extends Extract { + data: { settings: VaultSettings }; +} +``` + +- [ ] **Step 2: Add SW handlers** + +In `extension/src/service-worker/router/popup-only.ts`, inside the message `switch` statement, add: + +```ts +case 'get_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle); + return { ok: true, data: { settings } }; +} + +case 'update_vault_settings': { + const handle = session.getCurrent(); + if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; + await vault.encryptAndWriteSettings( + state.gitHost, handle, msg.settings, + 'settings: update vault-level config', + ); + return { ok: true }; +} +``` + +- [ ] **Step 3: Add router tests** + +Append to `extension/src/service-worker/router/__tests__/router.test.ts` (near existing describe blocks): + +```ts +describe('get_vault_settings / update_vault_settings', () => { + it('get_vault_settings accepted from popup; returns VaultSettings', async () => { + const state = makeState(); + primeUnlocked(state); + const mockSettings = { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: { + kind: 'random', length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, + }, + attachment_caps: {}, + autofill_origin_acks: { 'github.com': 1000 }, + }; + vi.mocked(vault.fetchAndDecryptSettings).mockResolvedValueOnce(mockSettings as any); + const res = await route({ type: 'get_vault_settings' }, state, makePopupSender()); + expect(res).toMatchObject({ ok: true }); + if (res.ok) { + const d = res.data as { settings: typeof mockSettings }; + expect(d.settings).toEqual(mockSettings); + } + }); + + it('get_vault_settings rejected from content', async () => { + const state = makeState(); + const res = await route({ type: 'get_vault_settings' }, state, makeContentSender()); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('update_vault_settings accepted from popup; calls encryptAndWriteSettings', async () => { + const state = makeState(); + primeUnlocked(state); + vi.mocked(vault.encryptAndWriteSettings).mockResolvedValueOnce(undefined); + const newSettings = { + trash_retention: { kind: 'forever' }, + field_history_retention: { kind: 'last_n', value: 5 }, + generator_defaults: { + kind: 'bip39', word_count: 6, separator: '-', capitalization: 'lower', + }, + attachment_caps: {}, + autofill_origin_acks: {}, + }; + const res = await route( + { type: 'update_vault_settings', settings: newSettings as any }, + state, + makePopupSender(), + ); + expect(res).toMatchObject({ ok: true }); + expect(vault.encryptAndWriteSettings).toHaveBeenCalledWith( + expect.anything(), expect.anything(), newSettings, expect.any(String), + ); + }); + + it('update_vault_settings rejected from setup tab (not in SETUP_ALLOWED)', async () => { + const state = makeState(); + const res = await route( + { type: 'update_vault_settings', settings: {} as any }, + state, + makeSetupSender(), + ); + expect(res).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); +``` + +Adapt the `primeUnlocked(state)` / `makePopupSender()` / `makeContentSender()` / `makeSetupSender()` helper invocations to match the existing patterns in the file. If `vault` isn't already mocked at the top of the file via `vi.mock(...)`, add: + +```ts +vi.mock('../../vault', () => ({ + fetchAndDecryptSettings: vi.fn(), + encryptAndWriteSettings: vi.fn(), + fetchAndDecryptManifest: vi.fn(), + fetchAndDecryptItem: vi.fn(), + encryptAndWriteItem: vi.fn(), + encryptAndWriteManifest: vi.fn(), + listItems: vi.fn(() => []), + findByHostname: vi.fn(() => []), + setWasm: vi.fn(), +})); +``` + +Check the existing mock set in `router.test.ts` and merge any missing functions. + +- [ ] **Step 4: Run tests** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: 105 + 4 = 109 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/messages.ts \ + extension/src/service-worker/router/popup-only.ts \ + extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "feat(ext/sw): get_vault_settings + update_vault_settings popup-only messages" +``` + +### Task 7: Add `generate_passphrase` message + handler (if missing) + +**Files:** +- Modify: `extension/src/shared/messages.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` + +- [ ] **Step 1: Check if `generate_passphrase` message already exists** + +```bash +grep -n "generate_passphrase" extension/src/shared/messages.ts +``` + +If it's already present (as of β₁), skip to Step 4. Otherwise proceed. + +- [ ] **Step 2: Add to PopupMessage + POPUP_ONLY_TYPES** + +In `extension/src/shared/messages.ts`: + +```ts + | { type: 'generate_passphrase'; request: GeneratorRequest } +``` + +Add `'generate_passphrase'` to the `POPUP_ONLY` array. + +- [ ] **Step 3: Add SW handler** + +In `router/popup-only.ts`, alongside the existing `generate_password` case: + +```ts +case 'generate_passphrase': { + const passphrase = state.wasm.generate_passphrase(JSON.stringify(msg.request)); + return { ok: true, data: { passphrase } }; +} +``` + +- [ ] **Step 4: Verify build + tests still pass** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +cd extension && bun run build 2>&1 | tail -3 +``` + +Expected: tests unchanged (109); build clean. + +- [ ] **Step 5: Commit (only if changes were made)** + +```bash +git status +git add -A +git commit -m "feat(ext/sw): generate_passphrase popup-only message" +``` + +If `git status` shows no staged changes (message already existed), skip the commit. + +### Task 8: Fetch `vault_settings` at popup init + add to PopupState + +**Files:** +- Modify: `extension/src/popup/popup.ts` + +- [ ] **Step 1: Extend PopupState** + +In `extension/src/popup/popup.ts`, find the `PopupState` interface and add: + +```ts + vaultSettings: import('../shared/types').VaultSettings | null; + generatorDefaults: import('../shared/types').GeneratorRequest | null; +``` + +Also extend the `View` union: + +```ts +export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault'; +``` + +Add initial values to `currentState`: + +```ts + vaultSettings: null, + generatorDefaults: null, +``` + +- [ ] **Step 2: Fetch on unlock** + +In `popup.ts#init`, after the `if (data.unlocked)` branch loads entries, add a parallel `get_vault_settings` fetch: + +```ts +// Existing code: +// if (data.unlocked) { +// const listResp = await sendMessage({ type: 'list_items' }); +// if (listResp.ok) { ... } +// } + +// Add: +const vsResp = await sendMessage({ type: 'get_vault_settings' }); +if (vsResp.ok) { + const vs = (vsResp.data as { settings: import('../shared/types').VaultSettings }).settings; + currentState.vaultSettings = vs; + currentState.generatorDefaults = vs.generator_defaults; +} +``` + +Place this AFTER the list fetch but BEFORE the `navigate('list', ...)` call so the state is populated before the list view renders. + +- [ ] **Step 3: Verify build + tests** + +```bash +cd extension && bun run build 2>&1 | tail -3 +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: clean; 109 tests. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/popup.ts +git commit -m "feat(ext/popup): fetch vault_settings on unlock; add to PopupState" +``` + +--- + +## Slice 4 — Generator inline popover + +### Task 9: Create `generator-popover.ts` + tests + +**Files:** +- Create: `extension/src/popup/components/generator-popover.ts` +- Create: `extension/src/popup/components/__tests__/generator-popover.test.ts` +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Write the failing tests** + +Create `extension/src/popup/components/__tests__/generator-popover.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const sendMessage = vi.fn(); + return { sendMessage }; +}); + +import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover'; +import { sendMessage } from '../../popup'; +import type { GeneratorRequest } from '../../../shared/types'; + +const DEFAULT_REQ: GeneratorRequest = { + kind: 'random', + length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, +}; + +function setupAnchor(): HTMLElement { + document.body.innerHTML = ''; + return document.getElementById('anchor')!; +} + +describe('generator-popover', () => { + beforeEach(() => { + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } }); + }); + + it('opens a popover with Random kind by default', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + expect(document.querySelector('.generator-popover')).not.toBeNull(); + expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true); + }); + + it('sends generate_password on knob change (debounced)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + // Wait past the initial preview's debounce + await new Promise((r) => setTimeout(r, 200)); + const slider = document.querySelector('#gen-length') as HTMLInputElement; + slider.value = '32'; + slider.dispatchEvent(new Event('input', { bubbles: true })); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls.filter( + ([msg]) => (msg as { type: string }).type === 'generate_password', + ); + const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest }; + expect(latest.request.kind).toBe('random'); + if (latest.request.kind === 'random') { + expect(latest.request.length).toBe(32); + } + }); + + it('BIP39 toggle swaps to generate_passphrase', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.getElementById('gen-kind-bip39') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 200)); + const calls = vi.mocked(sendMessage).mock.calls; + expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true); + }); + + it('use-this-value invokes onPicked with current preview and closes', async () => { + const anchor = setupAnchor(); + const onPicked = vi.fn(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-use') as HTMLButtonElement).click(); + expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT'); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); + + it('save-as-default sends update_vault_settings with the current request', async () => { + vi.mocked(sendMessage).mockImplementation(async (msg: any) => { + if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } }; + if (msg.type === 'get_vault_settings') { + return { ok: true, data: { settings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: DEFAULT_REQ, + attachment_caps: {}, + autofill_origin_acks: {}, + } } }; + } + if (msg.type === 'update_vault_settings') return { ok: true }; + return { ok: false, error: 'unhandled' }; + }); + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + (document.querySelector('#gen-save-default') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 50)); + const updateCall = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(updateCall).toBeDefined(); + const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } }; + expect(msg.settings.generator_defaults.kind).toBe('random'); + }); + + it('disables use-button when no char class selected (Random)', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + // Uncheck all 4 classes + for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) { + const cb = document.getElementById(id) as HTMLInputElement; + cb.checked = false; + cb.dispatchEvent(new Event('change', { bubbles: true })); + } + const useBtn = document.querySelector('#gen-use') as HTMLButtonElement; + expect(useBtn.disabled).toBe(true); + }); + + it('closeGeneratorPopover removes the DOM + handlers', async () => { + const anchor = setupAnchor(); + openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + await new Promise((r) => setTimeout(r, 200)); + closeGeneratorPopover(); + expect(document.querySelector('.generator-popover')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run — tests fail** + +```bash +cd extension && bun run test src/popup/components/__tests__/generator-popover.test.ts 2>&1 | tail -10 +``` + +Expected: import errors. + +- [ ] **Step 3: Create `generator-popover.ts`** + +Create `extension/src/popup/components/generator-popover.ts`: + +```ts +/// Inline generator popover — anchored to a "gen" button, renders a +/// live preview that updates as knobs change (150ms debounce). Single +/// underlying GeneratorRequest; kind toggle swaps between Random + +/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel. + +import { sendMessage } from '../popup'; +import type { GeneratorRequest, VaultSettings } from '../../shared/types'; + +interface UiKnobs { + kind: 'random' | 'bip39'; + // Random + length: number; + lower: boolean; + upper: boolean; + digits: boolean; + symbols: boolean; + symbolCharset: 'safe_only' | 'extended' | 'custom'; + customSymbols: string; + // BIP39 + wordCount: number; + separator: string; + capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed'; +} + +function knobsFromRequest(req: GeneratorRequest): UiKnobs { + const defaults: UiKnobs = { + kind: 'random', + length: 20, lower: true, upper: true, digits: true, symbols: true, + symbolCharset: 'safe_only', customSymbols: '', + wordCount: 5, separator: ' ', capitalization: 'lower', + }; + if (req.kind === 'random') { + return { + ...defaults, + kind: 'random', + length: req.length, + lower: req.classes.lower, + upper: req.classes.upper, + digits: req.classes.digits, + symbols: req.classes.symbols, + symbolCharset: req.symbol_charset.kind, + customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '', + }; + } + return { + ...defaults, + kind: 'bip39', + wordCount: req.word_count, + separator: req.separator, + capitalization: req.capitalization, + }; +} + +function requestFromKnobs(knobs: UiKnobs): GeneratorRequest { + if (knobs.kind === 'random') { + return { + kind: 'random', + length: knobs.length, + classes: { + lower: knobs.lower, upper: knobs.upper, + digits: knobs.digits, symbols: knobs.symbols, + }, + symbol_charset: + knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } : + knobs.symbolCharset === 'extended' ? { kind: 'extended' } : + { kind: 'custom', value: knobs.customSymbols }, + }; + } + return { + kind: 'bip39', + word_count: knobs.wordCount, + separator: knobs.separator, + capitalization: knobs.capitalization, + }; +} + +let activePopover: { + host: HTMLElement; + cleanup: () => void; +} | null = null; +let debounceTimer: ReturnType | null = null; + +export interface OpenPopoverOpts { + anchor: HTMLElement; + initial: GeneratorRequest; + onPicked: (value: string) => void; +} + +export function openGeneratorPopover(opts: OpenPopoverOpts): void { + closeGeneratorPopover(); + + const knobs = knobsFromRequest(opts.initial); + let currentPreview = ''; + + const host = document.createElement('div'); + host.className = 'generator-popover'; + document.body.appendChild(host); + + // Position below anchor + const rect = opts.anchor.getBoundingClientRect(); + host.style.top = `${rect.bottom + 6}px`; + host.style.left = `${rect.left}px`; + + const render = (): void => { + host.innerHTML = buildInnerHtml(knobs); + wireInner(); + refreshPreview(); + }; + + const refreshPreview = (): void => { + if (debounceTimer !== null) clearTimeout(debounceTimer); + debounceTimer = setTimeout(async () => { + debounceTimer = null; + const request = requestFromKnobs(knobs); + const msg = knobs.kind === 'random' + ? { type: 'generate_password' as const, request } + : { type: 'generate_passphrase' as const, request }; + const resp = await sendMessage(msg); + if (resp.ok) { + const d = resp.data as { password?: string; passphrase?: string }; + currentPreview = d.password ?? d.passphrase ?? ''; + const el = host.querySelector('.gen-preview__value'); + if (el) el.textContent = currentPreview; + updateValidation(); + } + }, 150); + }; + + const updateValidation = (): void => { + const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null; + if (!useBtn) return; + const noClass = knobs.kind === 'random' + && !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols); + useBtn.disabled = noClass; + const note = host.querySelector('.gen-validation'); + if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none'; + }; + + const wireInner = (): void => { + host.querySelector('#gen-kind-random')?.addEventListener('click', () => { + knobs.kind = 'random'; render(); + }); + host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => { + knobs.kind = 'bip39'; render(); + }); + + host.querySelector('#gen-length')?.addEventListener('input', (e) => { + knobs.length = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-length-val'); + if (out) out.textContent = String(knobs.length); + refreshPreview(); + }); + + for (const { id, key } of [ + { id: 'gen-lower', key: 'lower' as const }, + { id: 'gen-upper', key: 'upper' as const }, + { id: 'gen-digits', key: 'digits' as const }, + { id: 'gen-symbols', key: 'symbols' as const }, + ]) { + host.querySelector(`#${id}`)?.addEventListener('change', (e) => { + knobs[key] = (e.target as HTMLInputElement).checked; + refreshPreview(); + }); + } + + host.querySelectorAll('[data-symbol-charset]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset']; + render(); + }); + }); + + host.querySelector('#gen-word-count')?.addEventListener('input', (e) => { + knobs.wordCount = Number((e.target as HTMLInputElement).value); + const out = host.querySelector('#gen-word-count-val'); + if (out) out.textContent = String(knobs.wordCount); + refreshPreview(); + }); + + host.querySelectorAll('[data-separator]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.separator = btn.dataset.separator ?? ' '; + render(); + }); + }); + + host.querySelectorAll('[data-capitalization]').forEach((btn) => { + btn.addEventListener('click', () => { + knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization']; + render(); + }); + }); + + host.querySelector('.gen-preview__regen')?.addEventListener('click', () => { + refreshPreview(); + }); + + host.querySelector('#gen-use')?.addEventListener('click', () => { + opts.onPicked(currentPreview); + closeGeneratorPopover(); + }); + + host.querySelector('#gen-save-default')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) }; + await sendMessage({ type: 'update_vault_settings', settings: updated }); + const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null; + if (btn) { + const original = btn.textContent; + btn.textContent = 'saved'; + setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500); + } + }); + + host.querySelector('#gen-reset')?.addEventListener('click', async () => { + const getResp = await sendMessage({ type: 'get_vault_settings' }); + if (!getResp.ok) return; + const vs = (getResp.data as { settings: VaultSettings }).settings; + Object.assign(knobs, knobsFromRequest(vs.generator_defaults)); + render(); + }); + + host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover()); + host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover()); + }; + + const onOutsideClick = (e: MouseEvent) => { + if (!host.contains(e.target as Node) && e.target !== opts.anchor) { + closeGeneratorPopover(); + } + }; + const onEsc = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeGeneratorPopover(); + }; + + const cleanup = (): void => { + document.removeEventListener('click', onOutsideClick, true); + document.removeEventListener('keydown', onEsc); + host.remove(); + }; + + activePopover = { host, cleanup }; + + setTimeout(() => { + document.addEventListener('click', onOutsideClick, true); + document.addEventListener('keydown', onEsc); + }, 0); + + render(); +} + +export function closeGeneratorPopover(): void { + if (activePopover === null) return; + if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } + activePopover.cleanup(); + activePopover = null; +} + +// --- HTML builders --- + +function buildInnerHtml(knobs: UiKnobs): string { + return ` +
+ generate + +
+
+ kind +
+ + +
+
+ ${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)} +
+ + +
+ ${knobs.kind === 'random' + ? `` + : ''} +
+ + + + +
+ `; +} + +function buildRandomKnobs(k: UiKnobs): string { + return ` +
+ length + + ${k.length} +
+
+ + + + +
+
+ symbols +
+ + +
+
+ `; +} + +function buildBip39Knobs(k: UiKnobs): string { + const sepChip = (label: string, sep: string) => ` + + `; + const capChip = (label: string, val: string) => ` + + `; + return ` +
+ words + + ${k.wordCount} +
+
+ separator +
+ ${sepChip('space', ' ')} + ${sepChip('-', '-')} + ${sepChip('_', '_')} + ${sepChip('.', '.')} + ${sepChip(':', ':')} +
+
+
+ case +
+ ${capChip('lower', 'lower')} + ${capChip('upper', 'upper')} + ${capChip('first', 'first_of_each')} + ${capChip('title', 'title')} +
+
+ `; +} +``` + +- [ ] **Step 4: Add CSS** + +Append to `extension/src/popup/styles.css`: + +```css +/* --- generator popover (β₂ slice 4) --- */ +.generator-popover { + position: absolute; z-index: 9999999; + background: #161b22; border: 1px solid #30363d; border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); + padding: 14px; min-width: 300px; max-width: 340px; + font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9; +} +.generator-popover .gen-header { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 8px; +} +.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; } +.generator-popover .gen-close { + background: transparent; border: 0; color: #8b949e; cursor: pointer; + font-size: 14px; padding: 2px 6px; +} +.generator-popover .gen-row { + display: flex; align-items: center; gap: 8px; margin: 6px 0; +} +.generator-popover .gen-row__label { + color: #8b949e; width: 70px; flex-shrink: 0; + font-size: 10px; text-transform: lowercase; +} +.generator-popover .gen-toggle-group { + display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden; +} +.generator-popover .gen-toggle-group button { + background: transparent; border: 0; color: #8b949e; + padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px; +} +.generator-popover .gen-toggle-group button.active { background: #1f6feb; color: #fff; } +.generator-popover .gen-slider { flex: 1; } +.generator-popover .gen-slider + span { + color: #c9d1d9; font-variant-numeric: tabular-nums; + font-family: monospace; min-width: 24px; text-align: right; +} +.generator-popover .gen-check-grid { + display: grid; grid-template-columns: 1fr 1fr; + gap: 4px 16px; margin: 6px 0; font-size: 11px; +} +.generator-popover .gen-check-grid label { + display: flex; align-items: center; gap: 6px; +} +.generator-popover .gen-preview { + margin: 10px 0 8px; padding: 8px 10px; + background: #0d1117; border: 1px solid #30363d; border-radius: 4px; + font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9; + display: flex; justify-content: space-between; align-items: center; gap: 8px; + word-break: break-all; +} +.generator-popover .gen-preview__regen { + flex-shrink: 0; background: transparent; border: 0; + color: #58a6ff; cursor: pointer; font-size: 12px; +} +.generator-popover .gen-actions { + display: grid; grid-template-columns: 1fr 1fr; + gap: 6px; margin-top: 10px; +} +.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; } +``` + +- [ ] **Step 5: Run tests** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: 109 + 7 = 116 tests. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/popup/components/generator-popover.ts \ + extension/src/popup/components/__tests__/generator-popover.test.ts \ + extension/src/popup/styles.css +git commit -m "feat(ext/popup): generator-popover component (Random + BIP39)" +``` + +### Task 10: Wire gen button to popover + teardown integration + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts` +- Modify: `extension/src/popup/components/types/totp.ts` +- (Other type modules don't have "gen" buttons — only Login and Totp expose a password/secret field.) + +- [ ] **Step 1: Update Login's gen-button handler** + +In `extension/src/popup/components/types/login.ts`, find the existing gen-btn handler: + +```ts +document.getElementById('gen-btn')?.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST }); + if (resp.ok) { + const data = resp.data as { password: string }; + const pw = document.getElementById('f-password') as HTMLInputElement; + pw.value = data.password; + pw.type = 'text'; + } else setState({ error: resp.error }); +}); +``` + +Replace with: + +```ts +document.getElementById('gen-btn')?.addEventListener('click', (e) => { + const anchor = e.currentTarget as HTMLElement; + const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST; + openGeneratorPopover({ + anchor, + initial, + onPicked: (value) => { + const pw = document.getElementById('f-password') as HTMLInputElement | null; + if (pw) { pw.value = value; pw.type = 'text'; } + }, + }); +}); +``` + +Add the import at the top of the file: + +```ts +import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover'; +``` + +Extend the existing `teardown()` to close the popover: + +```ts +export function teardown(): void { + // ... existing teardown ... + closeGeneratorPopover(); +} +``` + +- [ ] **Step 2: Totp doesn't currently have a gen button** + +Totp's form has a secret input but no "gen" button today. β₂ doesn't add one — a "generate a TOTP secret" flow would be a γ polish item. Skip Totp changes for this task. + +- [ ] **Step 3: Verify build + tests** + +```bash +cd extension && bun run build 2>&1 | tail -3 +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: clean; 116 tests unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/components/types/login.ts +git commit -m "feat(ext/popup): login gen-btn opens generator popover; teardown closes it" +``` + +--- + +## Slice 5 — Settings view + ⚙ picker + default wiring + +### Task 11: Settings-vault component + tests + +**Files:** +- Create: `extension/src/popup/components/settings-vault.ts` +- Create: `extension/src/popup/components/__tests__/settings-vault.test.ts` +- Modify: `extension/src/popup/styles.css` + +- [ ] **Step 1: Write the failing tests** + +Create `extension/src/popup/components/__tests__/settings-vault.test.ts`: + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../popup', async () => { + const navigate = vi.fn(); + const setState = vi.fn(); + const sendMessage = vi.fn(); + const getState = vi.fn(() => ({ + view: 'settings-vault', + entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, + searchQuery: '', activeGroup: null, error: null, loading: false, + capturedTabId: null, capturedUrl: '', newType: null, + vaultSettings: { + trash_retention: { kind: 'days', value: 30 }, + field_history_retention: { kind: 'forever' }, + generator_defaults: { + kind: 'random', length: 20, + classes: { lower: true, upper: true, digits: true, symbols: true }, + symbol_charset: { kind: 'safe_only' }, + }, + attachment_caps: {}, + autofill_origin_acks: { 'github.com': 1000000000, 'example.com': 999000000 }, + }, + generatorDefaults: null, + })); + const escapeHtml = (s: string) => s + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return { navigate, setState, sendMessage, getState, escapeHtml }; +}); + +vi.mock('../generator-popover', () => ({ + openGeneratorPopover: vi.fn(), + closeGeneratorPopover: vi.fn(), +})); + +import { renderVaultSettings } from '../settings-vault'; +import { sendMessage } from '../../popup'; + +describe('settings-vault', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + vi.mocked(sendMessage).mockReset(); + vi.mocked(sendMessage).mockResolvedValue({ ok: true }); + }); + + it('renders with seeded vault-settings values', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + expect(app.textContent).toContain('vault settings'); + expect(app.textContent).toContain('github.com'); + expect(app.textContent).toContain('example.com'); + // Trash select should show "30 days"; history should show "Forever" + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + expect(trashSel.value).toBe('days:30'); + const histSel = document.getElementById('history-retention') as HTMLSelectElement; + expect(histSel.value).toBe('forever'); + }); + + it('renders origin acks sorted by recency (descending)', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const rows = Array.from(document.querySelectorAll('.ack-row__host')).map((e) => e.textContent); + expect(rows).toEqual(['github.com', 'example.com']); + }); + + it('save button disabled until a change is made', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement; + expect(saveBtn.disabled).toBe(true); + // Change trash retention + const trashSel = document.getElementById('trash-retention') as HTMLSelectElement; + trashSel.value = 'forever'; + trashSel.dispatchEvent(new Event('change', { bubbles: true })); + expect(saveBtn.disabled).toBe(false); + }); + + it('revoke button removes origin from pending and enables save', () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + expect(document.querySelector('[data-revoke="github.com"]')).toBeNull(); + expect((document.getElementById('save-btn') as HTMLButtonElement).disabled).toBe(false); + }); + + it('save button triggers update_vault_settings with pending', async () => { + const app = document.getElementById('app')!; + renderVaultSettings(app); + (document.querySelector('[data-revoke="github.com"]') as HTMLButtonElement).click(); + (document.getElementById('save-btn') as HTMLButtonElement).click(); + await new Promise((r) => setTimeout(r, 10)); + const call = vi.mocked(sendMessage).mock.calls.find( + ([m]) => (m as any).type === 'update_vault_settings', + ); + expect(call).toBeDefined(); + const payload = call![0] as { settings: any }; + expect(payload.settings.autofill_origin_acks).not.toHaveProperty('github.com'); + expect(payload.settings.autofill_origin_acks).toHaveProperty('example.com'); + }); +}); +``` + +- [ ] **Step 2: Create `settings-vault.ts`** + +Create `extension/src/popup/components/settings-vault.ts`: + +```ts +/// Vault-level settings screen. Covers retention (trash + field history), +/// generator defaults (preview + "configure" → opens popover), and +/// autofill origin-ack revocation. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { + VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, +} from '../../shared/types'; +import { openGeneratorPopover } from './generator-popover'; + +let pendingSettings: VaultSettings | null = null; +let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; + +export function teardown(): void { + if (activeKeyHandler) { + document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + } + pendingSettings = null; +} + +// --- Retention helpers --- + +function trashRetentionToValue(r: TrashRetention): string { + if (r.kind === 'forever') return 'forever'; + return `days:${r.value}`; +} + +function valueToTrashRetention(v: string): 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' }; // fallback +} + +function historyRetentionToValue(r: 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): 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' }; +} + +// --- Generator summary --- + +function generatorSummary(req: GeneratorRequest): string { + if (req.kind === 'random') { + const classes: string[] = []; + if (req.classes.lower) classes.push('lower'); + if (req.classes.upper) classes.push('upper'); + if (req.classes.digits) classes.push('digits'); + if (req.classes.symbols) classes.push('symbols'); + const sc = req.symbol_charset.kind; + return `Random, ${req.length} chars, ${classes.join('+') || 'no classes'}, ${sc} symbols`; + } + return `BIP39, ${req.word_count} words, "${req.separator}" separator, ${req.capitalization}`; +} + +// --- Time formatting --- + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +// --- Render --- + +export function renderVaultSettings(app: HTMLElement): void { + const state = getState(); + const base = state.vaultSettings; + if (!base) { + app.innerHTML = `

Vault settings not loaded yet.

`; + return; + } + pendingSettings = JSON.parse(JSON.stringify(base)) as VaultSettings; + + function rerender(): void { + if (!pendingSettings) return; + const acksEntries = Object.entries(pendingSettings.autofill_origin_acks) + .sort(([, a], [, b]) => b - a); + + app.innerHTML = ` +
+
+ +

vault settings

+
+ +
+
retention
+
+ trash + +
+
+ field history + +
+
+ +
+
generator
+

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

+ +
+ +
+
autofill origins
+ ${acksEntries.length === 0 + ? `

No origins acknowledged yet.

` + : acksEntries.map(([host, ts]) => ` +
+ ${escapeHtml(host)} + ${escapeHtml(relativeTime(ts))} + +
+ `).join('')} +
+ + +
+ `; + + // Set current select values + (document.getElementById('trash-retention') as HTMLSelectElement).value = + trashRetentionToValue(pendingSettings.trash_retention); + (document.getElementById('history-retention') as HTMLSelectElement).value = + historyRetentionToValue(pendingSettings.field_history_retention); + + wireHandlers(); + updateSaveEnabled(); + } + + function updateSaveEnabled(): void { + const saveBtn = document.getElementById('save-btn') as HTMLButtonElement | null; + if (!saveBtn || !pendingSettings || !base) return; + const changed = JSON.stringify(pendingSettings) !== JSON.stringify(base); + saveBtn.disabled = !changed; + } + + function wireHandlers(): void { + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list')); + + document.getElementById('trash-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.trash_retention = valueToTrashRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.getElementById('history-retention')?.addEventListener('change', (e) => { + if (!pendingSettings) return; + pendingSettings.field_history_retention = valueToHistoryRetention((e.target as HTMLSelectElement).value); + updateSaveEnabled(); + }); + + document.querySelectorAll('[data-revoke]').forEach((btn) => { + btn.addEventListener('click', () => { + if (!pendingSettings) return; + const host = btn.dataset.revoke ?? ''; + delete pendingSettings.autofill_origin_acks[host]; + rerender(); + }); + }); + + document.getElementById('configure-gen')?.addEventListener('click', (e) => { + if (!pendingSettings) return; + const anchor = e.currentTarget as HTMLElement; + openGeneratorPopover({ + anchor, + initial: pendingSettings.generator_defaults, + onPicked: () => {/* no-op — user is here to save as default, not pick */}, + }); + }); + + document.getElementById('save-btn')?.addEventListener('click', async () => { + if (!pendingSettings) return; + const resp = await sendMessage({ type: 'update_vault_settings', settings: pendingSettings }); + if (resp.ok) { + // Refresh cached state and navigate back. + const refreshed = await sendMessage({ type: 'get_vault_settings' }); + if (refreshed.ok) { + const vs = (refreshed.data as { settings: VaultSettings }).settings; + setState({ vaultSettings: vs, generatorDefaults: vs.generator_defaults }); + } + navigate('list'); + } else { + setState({ error: resp.error }); + } + }); + } + + rerender(); + + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + if (activeKeyHandler) document.removeEventListener('keydown', activeKeyHandler); + activeKeyHandler = null; + navigate('list'); + } + }; + activeKeyHandler = handler; + document.addEventListener('keydown', handler); +} +``` + +- [ ] **Step 3: Add CSS** + +Append to `extension/src/popup/styles.css`: + +```css +/* --- settings-vault screen (β₂ slice 5) --- */ +.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } +.settings-section { + margin-top: 14px; padding-top: 10px; + border-top: 1px solid #21262d; +} +.settings-section__title { + color: #8b949e; font-size: 10px; + text-transform: uppercase; letter-spacing: 0.08em; + margin-bottom: 6px; +} +.settings-row { + display: grid; grid-template-columns: 110px 1fr; + gap: 6px 10px; align-items: center; + margin: 4px 0; font-size: 12px; +} +.settings-row__label { color: #8b949e; } +.settings-row select { + background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px; +} +.gen-preview-line { + margin: 0 0 6px; font-size: 11px; color: #c9d1d9; + font-family: "SF Mono", "JetBrains Mono", monospace; +} +.ack-row { + display: grid; grid-template-columns: 1fr auto auto; + gap: 8px; align-items: center; + padding: 4px 0; font-size: 11px; + border-bottom: 1px solid #161b22; +} +.ack-row__host { color: #c9d1d9; font-family: monospace; } +.ack-row__meta { color: #6e7681; font-size: 10px; } +.ack-row__revoke { + background: transparent; border: 0; color: #f85149; + cursor: pointer; font-size: 10px; +} +.settings-footer { + display: flex; justify-content: flex-end; gap: 6px; + margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d; +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: 116 + 5 = 121 tests. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/popup/components/settings-vault.ts \ + extension/src/popup/components/__tests__/settings-vault.test.ts \ + extension/src/popup/styles.css +git commit -m "feat(ext/popup): vault-settings screen (retention + generator + origin-ack revoke)" +``` + +### Task 12: Wire ⚙ picker + settings-vault route + +**Files:** +- Modify: `extension/src/popup/popup.ts` +- Modify: `extension/src/popup/components/item-list.ts` + +- [ ] **Step 1: Add the settings-vault route** + +In `extension/src/popup/popup.ts`, find the `render()` function's `switch (currentState.view)` block and add: + +```ts +case 'settings-vault': + // Lazy import to keep bundle chunks focused. + import('./components/settings-vault').then((m) => m.renderVaultSettings(app)); + break; +``` + +Or, if all other views use synchronous imports, add: + +```ts +import { renderVaultSettings } from './components/settings-vault'; +// ... +case 'settings-vault': + renderVaultSettings(app); + break; +``` + +Match the existing import style used by the other view imports. + +- [ ] **Step 2: Replace the ⚙ button with a small picker** + +In `extension/src/popup/components/item-list.ts`, find the existing settings button handler (something like `document.getElementById('settings-btn')?.addEventListener('click', () => navigate('settings'));`) and replace with a picker, following the pattern from the `showNewTypePicker` helper that β₁ added: + +```ts +document.getElementById('settings-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + showSettingsPicker(e.currentTarget as HTMLElement); +}); +``` + +Add at the bottom of `item-list.ts`: + +```ts +const SETTINGS_OPTIONS: Array<{ view: 'settings' | 'settings-vault'; icon: string; label: string }> = [ + { view: 'settings', icon: '🖥', label: 'device settings' }, + { view: 'settings-vault', icon: '🔐', label: 'vault settings' }, +]; + +function showSettingsPicker(anchor: HTMLElement): void { + document.querySelectorAll('.settings-picker').forEach((el) => el.remove()); + + const picker = document.createElement('div'); + picker.className = 'settings-picker'; + Object.assign(picker.style, { + position: 'absolute', + background: '#161b22', + border: '1px solid #30363d', + borderRadius: '6px', + boxShadow: '0 4px 12px rgba(0,0,0,0.4)', + padding: '4px', + minWidth: '170px', + zIndex: '999999', + fontSize: '12px', + }); + + const rect = anchor.getBoundingClientRect(); + picker.style.top = `${rect.bottom + 4}px`; + picker.style.left = `${rect.left}px`; + + for (const opt of SETTINGS_OPTIONS) { + const row = document.createElement('div'); + Object.assign(row.style, { + padding: '6px 10px', cursor: 'pointer', color: '#c9d1d9', + borderRadius: '4px', display: 'flex', alignItems: 'center', gap: '8px', + }); + const iconSpan = document.createElement('span'); + iconSpan.textContent = opt.icon; + Object.assign(iconSpan.style, { fontSize: '14px', width: '16px', textAlign: 'center' }); + const labelSpan = document.createElement('span'); + labelSpan.textContent = opt.label; + row.appendChild(iconSpan); + row.appendChild(labelSpan); + row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); + row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); + row.addEventListener('click', (ev) => { + ev.stopPropagation(); + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + navigate(opt.view); + }); + picker.appendChild(row); + } + + document.body.appendChild(picker); + + const closeOnOutside = (ev: MouseEvent) => { + if (!picker.contains(ev.target as Node)) { + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + } + }; + const closeOnEsc = (ev: KeyboardEvent) => { + if (ev.key === 'Escape') { + picker.remove(); + document.removeEventListener('click', closeOnOutside); + document.removeEventListener('keydown', closeOnEsc); + } + }; + setTimeout(() => { + document.addEventListener('click', closeOnOutside); + document.addEventListener('keydown', closeOnEsc); + }, 0); +} +``` + +Verify `navigate` is already imported at the top of `item-list.ts`. It should be. + +- [ ] **Step 3: Verify build + tests** + +```bash +cd extension && bun run build 2>&1 | tail -3 +cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +``` + +Expected: clean; 121 tests unchanged. + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/popup/popup.ts extension/src/popup/components/item-list.ts +git commit -m "feat(ext/popup): ⚙ picker → device/vault settings" +``` + +### Task 13: Final acceptance + tag + +No file changes — pure verification + tag. + +- [ ] **Step 1: Rust + WASM target** + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2 +cargo test --workspace 2>&1 | grep "test result" +cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -3 +``` + +Expected: 155 tests pass (unchanged from β₁ baseline); WASM builds clean. + +- [ ] **Step 2: Extension builds + tests** + +```bash +cd extension +bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3 +bun run build:all 2>&1 | grep -E "warning|error|compiled" | tail -10 +``` + +Expected: ~121 tests; both bundles compile clean. + +- [ ] **Step 3: Lint greps** + +```bash +cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2 +echo "=== @ts-nocheck (should be empty) ===" +git grep -n '@ts-nocheck' extension/src/ +echo "---" +echo "=== idfoto (should be empty) ===" +git grep -n 'idfoto' extension/ +echo "---" +echo "=== innerHTML in content/ (should be empty) ===" +git grep -n 'innerHTML\|insertAdjacentHTML' extension/src/content/ | grep -v '^[^:]*:[0-9]*: *//' +``` + +Expected: all three empty. + +- [ ] **Step 4: Manual matrix on Chrome + Firefox** + +Build + reload: + +```bash +cd extension && bun run build:all +``` + +Load Chrome: `chrome://extensions` → reload the relicario card (or Load Unpacked from `extension/dist`). +Load Firefox: `about:debugging#/runtime/this-firefox` → Reload (or Load Temporary Add-on on `extension/dist-firefox/manifest.json`). + +For the manual matrix: + +1. **Custom fields — add**: Add a Login item; in the disclosure, add a section named "recovery codes" with two password fields; save; open detail → sections appear below typed rows; reveal works on each concealed row; copy works on text rows. +2. **Custom fields — edit**: Edit the same item; remove one field; add a text field; save; detail reflects all three changes. +3. **Custom fields — across types**: Repeat (1) on a SecureNote, Card, Key — sections should render identically in detail + edit across all 6 type detail views. +4. **Vault settings — retention**: Click ⚙ → vault settings; change trash retention to `7 days`; save; reload popup → still `7 days`. Change field-history retention to `Last 5`; save; reload → still `Last 5`. +5. **Vault settings — generator**: In vault settings, click "configure ▾" on the generator preview; change kind to BIP39, word count 7; click "save as default"; close popover; preview line shows "BIP39, 7 words, ...". Reload popup → still BIP39. In Login form, click "gen" → popover opens with BIP39 defaults inherited from settings. +6. **Generator popover — Random**: From a Login edit form, click "gen" → popover opens; switch back to Random kind; adjust length slider; toggle classes; preview updates per keystroke; "use this value" fills the password input + unmasks. +7. **Generator popover — validation**: Uncheck all 4 classes in Random → "use this value" disabled; check one → enabled. +8. **Origin-ack revoke**: Open vault settings; revoke an acknowledged origin; save; attempt autofill on that site from a Login item with matching URL → re-triggers the requires-ack flow (per α's content-callable handler). +9. **⚙ picker**: Click the ⚙ button → small picker appears with "device settings" + "vault settings". Each routes correctly. + +- [ ] **Step 5: Tag the branch tip** + +```bash +git tag plan-1c-beta2-complete +git log --oneline -1 +``` + +(Do NOT push — user decides on push timing.) + +--- + +## Self-review + +### Spec coverage + +- Custom fields detail render — Task 1-2 (helper + per-type integration). +- Custom fields edit render — Task 3-4 (helpers + per-type integration + save-shape smoke test). +- VaultSettings TS types tightened — Task 5. +- `get_vault_settings` / `update_vault_settings` messages + SW handlers + router tests — Task 6. +- `generate_passphrase` message (if missing) — Task 7. +- Popup `vaultSettings` + `generatorDefaults` state + fetch on unlock — Task 8. +- Generator popover component + tests + CSS — Task 9. +- Gen-button integration + teardown — Task 10. +- Settings-vault component + tests + CSS — Task 11. +- ⚙ picker + settings-vault route — Task 12. +- Final acceptance + tag — Task 13. + +### Placeholder scan + +No TBD / TODO / "similar to task N" / ambiguous placeholders. Every code step contains complete code. + +### Type consistency + +- `renderSections(item, idPrefix)` signature consistent across Task 1 definition and Task 2 call-sites. +- `generateFieldId` / `renderSectionsEditor` / `wireSectionsEditor` signatures consistent across Task 3 definition and Task 4 callers. +- `VaultSettings` shape (`TrashRetention` / `HistoryRetention` / `GeneratorRequest`) consistent between Task 5 type definition, Task 6 handler, Task 8 popup init, Task 11 settings-vault. +- `openGeneratorPopover({ anchor, initial, onPicked })` signature consistent between Task 9 definition, Task 10 Login wire, Task 11 settings-vault "configure" wire. +- `sectionsDraft: Section[]` mutated in place across rerender boundaries — correct since the array reference doesn't change, only its contents. +- `pendingSettings: VaultSettings | null` scoped to `settings-vault.ts` module; initialized per `renderVaultSettings` call; cleared by `teardown()`.