# 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()`.