Files
relicario/docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:29:10 -04:00

2651 lines
94 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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</);
});
it('renders anonymous section with separator not header', () => {
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</);
});
});
```
- [ ] **Step 2: Run — tests fail (renderSections not exported)**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta2/extension
bun run test src/popup/components/__tests__/sections-render.test.ts 2>&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 += `<div class="section-header">${escapeHtml(section.name)}</div>`;
} else {
out += `<hr class="section-separator">`;
}
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 = `
<div class="pad">
${/* signature block */}
${/* typed rows (renderRow / renderConcealedRow) */}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
...
</div>
</div>
`;
```
Insert `${renderSections(item, '<type-slug>')}` immediately before the `<div class="form-actions">`:
- `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 `
<div class="disclosure" data-expanded="${expanded ? 'true' : 'false'}">
<button type="button" class="disclosure__toggle">▾ custom sections & fields (${escapeHtml(summary)})</button>
<div class="disclosure__body">
${body}
<button type="button" class="add-section">+ add section</button>
</div>
</div>
`;
}
function renderSectionBlock(section: Section, sIdx: number): string {
const nameDisplay = section.name
? `<span class="name">${escapeHtml(section.name)}</span>`
: `<span class="name anon">(anonymous)</span>`;
const fieldsHtml = section.fields.map((f, fIdx) => renderEditorField(f, sIdx, fIdx)).join('');
return `
<div class="section-editor" data-section-idx="${sIdx}">
<div class="section-editor__head">
${nameDisplay}
<span class="actions">
<button type="button" data-rename-section="${sIdx}">rename</button>
<button type="button" data-remove-section="${sIdx}">× remove section</button>
</span>
</div>
${fieldsHtml}
<div class="section-editor__add">
<button type="button" data-add-field="text" data-section-idx="${sIdx}">+ text</button>
<button type="button" data-add-field="password" data-section-idx="${sIdx}">+ password</button>
<button type="button" data-add-field="concealed" data-section-idx="${sIdx}">+ concealed</button>
</div>
</div>
`;
}
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 `
<div class="section-editor__field">
<input type="text" data-field-label="${key}" value="${escapeHtml(field.label)}" placeholder="label">
<input type="${inputType}" data-field-value-input="${key}" value="${escapeHtml(valueStr)}" placeholder="value">
<button type="button" class="delete-field" data-delete-field="${key}">×</button>
</div>
`;
}
/// 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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLInputElement>('[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<HTMLInputElement>('[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 `<div class="form-actions">` 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 = '<div id="app"></div>';
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<string, number>;
}
```
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<string, number>;
}
```
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 35 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<PopupMessage['type']> = [
// ... existing ...
'get_vault_settings',
'update_vault_settings',
];
export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = 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<Response, { ok: true }> {
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 = '<button id="anchor">gen</button>';
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<typeof setTimeout> | 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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-separator]').forEach((btn) => {
btn.addEventListener('click', () => {
knobs.separator = btn.dataset.separator ?? ' ';
render();
});
});
host.querySelectorAll<HTMLButtonElement>('[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 `
<div class="gen-header">
<span class="gen-title">generate</span>
<button type="button" id="gen-close" class="gen-close">×</button>
</div>
<div class="gen-row">
<span class="gen-row__label">kind</span>
<div class="gen-toggle-group">
<button id="gen-kind-random" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
<button id="gen-kind-bip39" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
</div>
</div>
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
<div class="gen-preview">
<span class="gen-preview__value"></span>
<button type="button" class="gen-preview__regen" title="regenerate">↻</button>
</div>
${knobs.kind === 'random'
? `<p class="gen-validation" style="display:none;color:#f85149;font-size:10px;margin:4px 0 0;">pick at least one character class</p>`
: ''}
<div class="gen-actions">
<button type="button" class="btn" id="gen-reset">reset to defaults</button>
<button type="button" class="btn" id="gen-save-default">save as default</button>
<button type="button" class="btn" id="gen-cancel">cancel</button>
<button type="button" class="btn btn-primary" id="gen-use">use this value</button>
</div>
`;
}
function buildRandomKnobs(k: UiKnobs): string {
return `
<div class="gen-row">
<span class="gen-row__label">length</span>
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider">
<span id="gen-length-val">${k.length}</span>
</div>
<div class="gen-check-grid">
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
</div>
<div class="gen-row">
<span class="gen-row__label">symbols</span>
<div class="gen-toggle-group">
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
</div>
</div>
`;
}
function buildBip39Knobs(k: UiKnobs): string {
const sepChip = (label: string, sep: string) => `
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button>
`;
const capChip = (label: string, val: string) => `
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
`;
return `
<div class="gen-row">
<span class="gen-row__label">words</span>
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider">
<span id="gen-word-count-val">${k.wordCount}</span>
</div>
<div class="gen-row">
<span class="gen-row__label">separator</span>
<div class="gen-toggle-group">
${sepChip('space', ' ')}
${sepChip('-', '-')}
${sepChip('_', '_')}
${sepChip('.', '.')}
${sepChip(':', ':')}
</div>
</div>
<div class="gen-row">
<span class="gen-row__label">case</span>
<div class="gen-toggle-group">
${capChip('lower', 'lower')}
${capChip('upper', 'upper')}
${capChip('first', 'first_of_each')}
${capChip('title', 'title')}
</div>
</div>
`;
}
```
- [ ] **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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 = '<div id="app"></div>';
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 = `<div class="pad"><p class="muted">Vault settings not loaded yet.</p></div>`;
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 = `
<div class="pad">
<div class="settings-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">vault settings</h3>
</div>
<div class="settings-section">
<div class="settings-section__title">retention</div>
<div class="settings-row">
<span class="settings-row__label">trash</span>
<select id="trash-retention">
<option value="forever">Forever</option>
<option value="days:7">7 days</option>
<option value="days:30">30 days</option>
<option value="days:60">60 days</option>
<option value="days:90">90 days</option>
<option value="days:180">180 days</option>
<option value="days:365">365 days</option>
</select>
</div>
<div class="settings-row">
<span class="settings-row__label">field history</span>
<select id="history-retention">
<option value="forever">Forever</option>
<option value="last_n:3">Last 3</option>
<option value="last_n:5">Last 5</option>
<option value="last_n:10">Last 10</option>
<option value="days:30">30 days</option>
<option value="days:90">90 days</option>
<option value="days:365">365 days</option>
</select>
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">generator</div>
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
<button class="btn" id="configure-gen">configure ▾</button>
</div>
<div class="settings-section">
<div class="settings-section__title">autofill origins</div>
${acksEntries.length === 0
? `<p class="muted">No origins acknowledged yet.</p>`
: acksEntries.map(([host, ts]) => `
<div class="ack-row">
<span class="ack-row__host">${escapeHtml(host)}</span>
<span class="ack-row__meta">${escapeHtml(relativeTime(ts))}</span>
<button class="ack-row__revoke" data-revoke="${escapeHtml(host)}">revoke</button>
</div>
`).join('')}
</div>
<div class="settings-footer">
<button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
</div>
</div>
`;
// 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<HTMLButtonElement>('[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()`.