Files
relicario/docs/superpowers/plans/2026-04-24-relicario-extension-1c-beta2.md
adlee-was-taken 2ca563a8cd docs: Plan 1C-β₂ (custom fields + settings + generator UI) implementation plan
13 tasks across 5 slices + pre-flight + acceptance. Follows α/β₁'s
cadence — each task one commit, each step 2-5 minutes, complete
code in every step.

Slice 1 — Custom-fields detail rendering (Tasks 1-2):
  renderSections helper + 6-type-module integration.
Slice 2 — Custom-fields edit rendering (Tasks 3-4):
  renderSectionsEditor + wireSectionsEditor + generateFieldId
  helpers, disclosure integration across all 6 forms, per-type
  save-shape smoke test.
Slice 3 — Vault-settings SW plumbing (Tasks 5-8):
  tighten VaultSettings TS types; add get/update_vault_settings
  popup-only messages + router tests; add generate_passphrase if
  missing; fetch vault_settings on popup unlock.
Slice 4 — Generator inline popover (Tasks 9-10):
  generator-popover component + 7 unit tests; Login gen-btn
  integration + teardown hook.
Slice 5 — Settings view + ⚙ picker (Tasks 11-13):
  settings-vault component + 5 tests; ⚙ picker → device/vault
  routes; final lint greps + tag.

Expected test delta: 84 → ~121 Vitest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 00:09:25 -04:00

94 KiB
Raw Blame History

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

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

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:

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

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:

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

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

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:

/* --- 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
cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3

Expected: 91 + ~13 = 104 tests pass.

  • Step 6: Commit
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:

import {
  renderRow, renderConcealedRow, renderSignatureBlock,
  wireFieldHandlers, renderSections,
  renderSectionsEditor, wireSectionsEditor,
} from '../fields';
import type { Section } from '../../../shared/types';

Add module-scope state near the existing activeKeyHandler:

let sectionsExpanded = false;

Extend teardown() to reset the expanded flag:

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:

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:

${renderSectionsEditor(sectionsDraft, sectionsExpanded)}

After app.innerHTML = ..., add a rerender closure and call wireSectionsEditor:

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:

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

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

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:

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

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

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

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:

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:

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

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:

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
cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3

Expected: 105 + 4 = 109 tests pass.

  • Step 5: Commit
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

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:

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

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

  vaultSettings: import('../shared/types').VaultSettings | null;
  generatorDefaults: import('../shared/types').GeneratorRequest | null;

Also extend the View union:

export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';

Add initial values to currentState:

  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:

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

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

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

/* --- 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
cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3

Expected: 109 + 7 = 116 tests.

  • Step 6: Commit
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:

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:

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:

import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';

Extend the existing teardown() to close the popover:

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

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:

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

/* --- 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
cd extension && bun run test 2>&1 | grep -E "Tests|Test Files" | tail -3

Expected: 116 + 5 = 121 tests.

  • Step 5: Commit
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:

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:

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:

document.getElementById('settings-btn')?.addEventListener('click', (e) => {
  e.stopPropagation();
  showSettingsPicker(e.currentTarget as HTMLElement);
});

Add at the bottom of item-list.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
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
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
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
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
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:

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