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

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

34 KiB
Raw Blame History

Relicario — Extension Plan 1C-β₂ (Custom Fields + Settings + Generator UI) Design

Third of three β sub-plans porting the extension to the typed-item core. 1C-α shipped the security architecture + Login parity; 1C-β₁ added the 5 remaining typed-item forms; 1C-β₂ (this spec) adds the cross-cutting UI surfaces: custom fields editor, full vault-settings view, and an inline generator popover.

Reference specs: docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md (α, commits a1d733d + ad6d8af), docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md (β₁, commit 1b51b7d). Both implementations merged to main: α at 2b83105 (tag plan-1c-alpha-complete), β₁ at 81fbe13 (tag plan-1c-beta1-complete).

Plan 1C decomposition (final shape)

Sub-plan Status Scope
1C-α shipped 2026-04-22 WASM rebuild, typed-item shared TS types, SessionHandle SW, split router with sender checks, closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate
1C-β₁ shipped 2026-04-22 5 remaining typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam-Guard alphabet patch; shared field helpers + Login refactor
1C-β₂ (this spec) proposed Custom-fields editor (Text/Password/Concealed), full VaultSettings view (retention + generator defaults + origin-ack revoke), advanced generator popover
1C-γ proposed Attachments (with putBlob Git-Data-API fallback), Document type, trash view, field-history view, device management, attachment caps UI

Design Decisions (from brainstorming)

Question Decision Why
Custom-fields scope Tier 1 — Text/Password/Concealed only, no reordering The other 8 FieldKinds (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline) each add real UX work; tier 1 covers the "recovery codes, security questions" 90% case. Reordering and additional kinds live in a later polish pass.
VaultSettings scope Retention + generator defaults + origin-ack revoke; skip attachment caps Attachment caps govern a feature that doesn't ship until γ. Ship the caps UI alongside the feature.
Generator UI location Inline popover + Settings preview One underlying GeneratorRequest config, two entry points. Matches 1Password/Bitwarden. "save as default" in the popover updates Settings without forcing the user to navigate.
Custom-fields edit-view placement Collapsible disclosure ("▸ custom sections & fields (N)") Most items never grow custom fields; always-visible editor adds clutter for the 90% case. Count-hint on the disclosure gives discoverability without noise.
Sequencing 5 slices: detail render → edit render → vault-settings SW (+ generate_passphrase if missing) → generator popover → settings view Matches β₁'s cadence. SW plumbing lands before the popover so "save as default" is fully functional the moment the popover ships.

Scope

In

  • Custom-fields rendering (detail view): Item.sections rendered below typed rows via a new renderSections(item, idPrefix) helper in fields.ts. Sections with ≥1 field render a header (named) or thin separator (anonymous). Fields of kind text render via renderRow; password/concealed via renderConcealedRow with per-section unique IDs.

  • Custom-fields editor (edit view): collapsible disclosure ("▸ custom sections & fields (N)") at the bottom of every type's form. Expanded state shows each section's rename/remove buttons, per-field label + value inputs + × delete, and per-section [+ text] [+ password] [+ concealed] buttons. A [+ add section] button at the bottom. Sections have optional names (rename via prompt(); clear to make anonymous). Save packs sectionsDraft into the outgoing Item.sections.

  • FieldKind support: text, password, concealed only. Url / Email / Phone / Date / MonthYear / Totp / Reference / Multiline all remain Rust-core-only (the data model supports them; the popup doesn't render editors for them in β₂).

  • No reordering: new fields append to their section's fields array; new sections append to item.sections. Rendering preserves array order. A future polish pass can add up/down arrows or drag handles.

  • Full VaultSettings view: new popup/components/settings-vault.ts screen wired to the ⚙ toolbar button (now a tiny picker: device / vault). Covers:

    • Trash retention (Days(N) / Forever) via a preset dropdown (Forever / 7 / 30 / 60 / 90 / 180 / 365 / custom days).
    • Field-history retention (LastN(N) / Days(N) / Forever) via a preset dropdown (Forever / Last 3 / Last 5 / Last 10 / 30 days / 90 days / 365 days / custom).
    • Generator-default preview with a "configure ▾" button that opens the same generator popover used at form "gen" sites; "save as default" closes the loop.
    • Origin-ack list (autofill_origin_acks) sorted by most-recent first, with per-host revoke buttons.
    • Save-changes / discard buttons; save disabled until pendingSettings differs from vaultSettings.
  • Advanced generator popover: new popup/components/generator-popover.ts. Anchored to the "gen" button; positioned absolutely below. Kind toggle (Random / BIP39). Random knobs: length slider (8-64), 4 char-class checkboxes, symbol-charset toggle (safe_only / extended / custom). BIP39 knobs: word count slider (3-12), separator chip picker (space / - / _ / . / :), capitalization picker (lower / upper / first-of-each / title). Live preview via generate_password / generate_passphrase message on 150ms debounce. Four action buttons: reset to defaults, save as default, cancel, use this value. Validation: "use this value" disabled when no char class selected for Random kind.

  • New popup-only messages: get_vault_settings → returns full VaultSettings. update_vault_settings → writes full VaultSettings. Both added to POPUP_ONLY_TYPES; not in SETUP_ALLOWED. Router test matrix grows by 4 cases (accept from popup × 2, reject from content × 2).

  • Teardown integration: every type module's teardown() gains closeGeneratorPopover(). The collapsible disclosure's expanded-state (sectionsExpanded: boolean) is module-scope and reset by teardown().

Out (→ γ / later)

  • Reordering (sections or fields-within-section).
  • Other FieldKind variants (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline).
  • Attachment caps UI (γ concern, bundled with attachments).
  • Bulk custom-field operations (delete-many, template, import-from-CSV).
  • Per-type section templates (e.g., Card auto-creates a "billing address" section).
  • Item-to-item Reference pointers (requires attachment picker).

Architecture

Data flow additions

  1. Custom fields: already present end-to-end — the Rust core's Item.sections: Vec<Section> + Section.fields: Vec<Field> + Field.value: FieldValue data model is complete. β₁'s save paths already pass sections: existing?.sections ?? [] through. β₂ just grows the UI to produce and consume that shape. No SW message changes.

  2. Vault settings: α plumbed fetchAndDecryptSettings / encryptAndWriteSettings through service-worker/vault.ts for the autofill origin-ack writes. β₂ exposes the full VaultSettings object via two new popup-only messages. No new Rust or WASM work.

  3. Generator popover: already has all the plumbing it needs — α's generate_password / generate_passphrase messages accept an arbitrary GeneratorRequest and route to the WASM layer. β₂ just wires a UI.

Module boundaries

popup/components/
  fields.ts                     (extended) — + renderSections, renderSectionsEditor,
                                              wireSectionsEditor, generateFieldId
  generator-popover.ts          (new)      — openGeneratorPopover, closeGeneratorPopover
  settings-vault.ts             (new)      — renderVaultSettings
  item-list.ts                  (edit)     — ⚙ toolbar button → device/vault picker
  types/login.ts                (edit)     — + sections tail in renderDetail;
                                              + disclosure in renderForm;
                                              + generator popover wire on "gen" button;
                                              + closeGeneratorPopover in teardown
  types/{secure-note,identity,card,key,totp}.ts  (edit) — same integration pattern

service-worker/
  router/popup-only.ts          (edit)     — + get_vault_settings, update_vault_settings

shared/
  messages.ts                   (edit)     — + 2 new PopupMessage variants, added to POPUP_ONLY_TYPES
  types.ts                      (unchanged)

popup/popup.ts                  (edit)     — + vaultSettings + generatorDefaults in PopupState;
                                              + fetch after unlock; + settings-vault view route

PopupState additions

vaultSettings: VaultSettings | null;        // cached on unlock; refreshed on save
generatorDefaults: GeneratorRequest | null; // derived from vaultSettings.generator_defaults
view: 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';

The 'settings-vault' view routes to the new renderVaultSettings.

Slice 1 — Custom-fields detail rendering

fields.ts#renderSections

export function renderSections(item: Item, idPrefix: string): string;
  • Walks item.sections. For each section with ≥1 field:
    • If section.name truthy: emit <div class="section-header">{escaped name}</div>
    • Else (anonymous): emit <hr class="section-separator">
    • For each field:
      • field.value.kind === 'text'renderRow({ label: field.label, value: field.value.value, copyable: true })
      • field.value.kind === 'password' / 'concealed'renderConcealedRow({ id: ${idPrefix}-s${sectionIdx}-f${fieldIdx}, label: field.label, value: field.value.value })
      • Other kinds: silently skip in β₂ (the Rust core may carry other-kind fields from the CLI; we render what we support).

Per-type integration

Every type module's renderDetail gets a call to renderSections between typed rows and action buttons:

app.innerHTML = `
  <div class="pad">
    ${/* signature block + typed rows */}
    ${renderSections(item, '<type>')}   // ← added
    ${/* form-actions */}
  </div>
`;

wireFieldHandlers(app) call already at the bottom of each type module picks up the new reveal/copy buttons in custom-field rows.

Tests

types/__tests__/sections-render.test.ts:

  • Empty item.sectionsrenderSections returns empty string.
  • One named section with 2 text fields → contains the section name + both field labels + both values as visible text.
  • Mixed text + password fields → password value concealed (not in visible DOM text); has reveal button.
  • Anonymous section → separator HR, no name header.
  • Unsupported kind (e.g., a date field from the CLI) → silently skipped, no error.

CSS

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

Slice 2 — Custom-fields edit rendering

fields.ts#renderSectionsEditor + wireSectionsEditor

export function renderSectionsEditor(sections: Section[], expanded: boolean): string;

/// Wire handlers for the editor's interactive elements. Mutations to
/// `sectionsDraft` are reflected by `rerender()` — callers implement
/// rerender by re-running `renderSectionsEditor` + inserting it back
/// into the disclosure's body element.
export function wireSectionsEditor(
  scope: HTMLElement,
  sectionsDraft: Section[],
  rerender: () => void,
): void;

Layout (expanded state)

▾ custom sections & fields (2 sections, 5 fields)

── recovery codes ──────  [rename] [× remove section]
 [label_________] [value_________________] [×]
 [label_________] [value_________________] [×]
 [+ text]  [+ password]  [+ concealed]

── (anonymous) ─────────  [rename] [× remove section]
 [label_________] [value_________________] [×]
 [+ text]  [+ password]  [+ concealed]

[+ add section]

generateFieldId

/// Client-side 16-char hex FieldId. Uses crypto.getRandomValues for
/// 8 random bytes; matches the wire-format requirement. No SW round-trip.
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('');
}

Mutations

  • Add section: sectionsDraft.push({ name: undefined, fields: [] }); rerender.

  • Rename section: prompt('Section name (empty for none):', section.name ?? ''); set sectionsDraft[i].name = result.trim() || undefined; rerender.

  • Remove section: confirm('Remove section ...?'); sectionsDraft.splice(i, 1); rerender.

  • Add field (kind K): sectionsDraft[i].fields.push(makeField(K)); rerender. Helper:

    function makeField(kind: 'text' | 'password' | 'concealed'): Field {
      const hidden = kind !== 'text';
      return {
        id: generateFieldId(),
        label: 'new field',
        kind,
        value: { kind, value: '' } as FieldValue,
        hidden_by_default: hidden,
      };
    }
    
  • Remove field: sectionsDraft[i].fields.splice(j, 1); rerender.

  • Edit field label: input event on label input mutates sectionsDraft[i].fields[j].label in place. No rerender (would steal focus).

  • Edit field value: input event mutates sectionsDraft[i].fields[j].value.value in place. No rerender.

Per-type form integration

Each of the 6 type modules (types/<x>.ts):

  1. At the top of renderForm, initialize a local sectionsDraft: Section[] = existing?.sections.map(deepClone) ?? [] (deep clone so cancel doesn't mutate the pre-existing item).
  2. Add let sectionsExpanded = false; at module scope, reset by teardown().
  3. Insert ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} in the form HTML, just before <div class="form-actions">.
  4. After app.innerHTML = ..., call wireSectionsEditor(app, sectionsDraft, rerender) where rerender replaces the disclosure subtree's innerHTML with a fresh renderSectionsEditor(sectionsDraft, sectionsExpanded).
  5. In save, replace sections: existing?.sections ?? [] with sections: sectionsDraft.

deepClone helper: JSON.parse(JSON.stringify(existing.sections)) is sufficient for the Section[] shape (no class instances, no Date objects, no undefined in positions that need to survive).

Tests

types/__tests__/sections-edit.test.ts:

  • Open form (add mode), click disclosure toggle → data-expanded flips true.
  • Click "+ add section" → one section appears; its field list is empty.
  • Rename the section via mocked window.prompt → section header updates.
  • Click "+ text" → a text field appears with label "new field" and empty value.
  • Edit the label + value inputs → assertions on the in-memory sectionsDraft.
  • Click save → add_item message's item.sections matches the draft structure.
  • Round-trip on edit mode: pre-populate existing with sections, open form, confirm sections render expanded (since count > 0), add a field, save → outgoing sections has the new field appended.

CSS additions

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

Slice 3 — Vault-settings SW plumbing

Messages

shared/messages.ts — add to PopupMessage:

  | { type: 'get_vault_settings' }
  | { type: 'update_vault_settings'; settings: VaultSettings }

Add both to POPUP_ONLY_TYPES. NOT in SETUP_ALLOWED.

Add:

export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
  data: { settings: VaultSettings };
}

Handlers (router/popup-only.ts)

case 'get_vault_settings': {
  const handle = session.getCurrent();
  if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
  const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
  return { ok: true, data: { settings } };
}

case 'update_vault_settings': {
  const handle = session.getCurrent();
  if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
  await vault.encryptAndWriteSettings(
    state.gitHost, handle, msg.settings,
    'settings: update vault-level config',
  );
  return { ok: true };
}

Router tests

router/__tests__/router.test.ts (+4 cases):

  • get_vault_settings accepted from popup (mock fetchAndDecryptSettings → returns a VaultSettings); response shape matches VaultSettingsResponse.
  • get_vault_settings rejected from content → unauthorized_sender.
  • update_vault_settings accepted from popup; calls encryptAndWriteSettings.
  • update_vault_settings rejected from setup tab (not in SETUP_ALLOWED).

Popup init

popup.ts#init, after a successful unlock-is-active branch:

const vsResp = await sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
  const vs = (vsResp.data as { settings: VaultSettings }).settings;
  currentState.vaultSettings = vs;
  currentState.generatorDefaults = vs.generator_defaults as GeneratorRequest;
}

Fetched once at popup open; refreshed after any update_vault_settings success. The "fetch on open" cost is one extra round-trip over α — acceptable given vault-settings drives multiple screens.

generate_passphrase message (add if missing)

The α plan lists generate_password as a popup-only message. The generator popover (Slice 4) also needs generate_passphrase for BIP39 preview. Check shared/messages.ts; if absent, add:

  | { type: 'generate_passphrase'; request: GeneratorRequest }

Add to POPUP_ONLY_TYPES. The SW handler mirrors generate_password but calls the generate_passphrase WASM function. One new case in router/popup-only.ts.

Slice 4 — Generator inline popover

popup/components/generator-popover.ts

export function openGeneratorPopover(opts: {
  anchor: HTMLElement;
  initial: GeneratorRequest;
  onPicked: (value: string) => void;
}): void;

export function closeGeneratorPopover(): void;

Module-scope state:

let activePopover: {
  host: HTMLElement;
  onDismiss: () => void;
} | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;

Layout (Random kind)

┌─ generate ────────────────── ✕ ┐
│                                │
│ kind:  [● Random] [○ BIP39]    │
│                                │
│ length: [════●═══════] 20       │
│                                │
│ ☑ lowercase   ☑ digits          │
│ ☑ uppercase   ☑ symbols         │
│                                │
│ symbols:  [● safe] [○ extended] │
│                                │
│ ─ preview ──────────────────── │
│  Kj7%pW@2xNq!8rMvT   [↻]        │
│                                │
│ [reset to defaults]             │
│ [save as default]               │
│ [cancel]      [use this value]  │
└─────────────────────────────────┘

Layout (BIP39 kind)

┌─ generate ────────────────── ✕ ┐
│ kind:  [○ Random] [● BIP39]    │
│                                │
│ words:   [═══●════════] 5       │
│                                │
│ separator:  [space] [-] [_] [.] [:]
│                                │
│ capitalization:                │
│   [● lower] [upper] [first] [title]
│                                │
│ ─ preview ──────────────────── │
│  correct horse battery staple parapet
│                                │
│ [reset to defaults]             │
│ [save as default]               │
│ [cancel]      [use this value]  │
└─────────────────────────────────┘

Request construction

function buildRequest(kind: 'random' | 'bip39', knobs: UiKnobs): GeneratorRequest {
  if (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,
  };
}

Preview refresh

On any knob change, debounced 150ms:

async function refreshPreview(): Promise<void> {
  const request = buildRequest(uiKind, uiKnobs);
  const msg = uiKind === 'random'
    ? { type: 'generate_password' as const, request }
    : { type: 'generate_passphrase' as const, request };
  const resp = await sendMessage(msg);
  if (resp.ok) {
    const data = resp.data as { password?: string; passphrase?: string };
    const previewEl = activePopover?.host.querySelector('.gen-preview__value');
    if (previewEl) previewEl.textContent = data.password ?? data.passphrase ?? '';
  }
}

Note: α added generate_password but generate_passphrase may need to be added (check α's messages.ts). If not present, add it alongside generate_password in slice 4's scope (router handler already accepts a request_json → WASM generate_passphrase).

Validation

"use this value" button disabled when:

  • Random kind and no char-class checked (!lower && !upper && !digits && !symbols).
  • BIP39 kind never disabled (always valid — word count ≥ 3).

Visual cue: when disabled, button is dimmed + a <p class="gen-validation">pick at least one character class</p> renders below.

Actions

  • use this value: onPicked(currentPreview); close();. Host field's setter wraps this (e.g., pw.value = value; pw.type = 'text'; for the Login form).
  • save as default: fetch the full vaultSettings via sendMessage({ type: 'get_vault_settings' }); write { ...vaultSettings, generator_defaults: currentRequest } via update_vault_settings. On success: update state.vaultSettings + state.generatorDefaults; flash "saved" on the button for 1.5s; do NOT close.
  • reset to defaults: reset UI knobs to state.generatorDefaults ?? DEFAULT_PASSWORD_REQUEST; refresh preview.
  • cancel / Escape / outside-click: close without callback.

Teardown wiring

Every type module's existing teardown() gains:

closeGeneratorPopover();

So navigation or re-rendering always cleans up the popover.

Tests

__tests__/generator-popover.test.ts (mocks sendMessage):

  • Open with default initial → renders Random kind, shows length=20, all 4 classes checked, safe_only.
  • BIP39 toggle → switches knobs to word-count / separator / capitalization; sendMessage called with generate_passphrase.
  • Length slider change → debounced generate_password call with updated length.
  • "use this value" → onPicked called with current preview string; popover closes.
  • "save as default" → update_vault_settings called with the current request merged into vaultSettings.
  • Uncheck all 4 classes in Random → "use this value" button disabled.
  • Escape key → popover closes without invoking onPicked.

Slice 5 — Settings view + revoke + default wiring

Routing

popup.ts:

  • Add 'settings-vault' to the View union.
  • Add the render-switch case pointing at renderVaultSettings.
  • Toolbar ⚙ button on item-list.ts becomes a tiny picker (render inline, same pattern as the "+ New" picker):
⚙
├ device settings    → navigate('settings')
└ vault settings     → navigate('settings-vault')

popup/components/settings-vault.ts

export function renderVaultSettings(app: HTMLElement): void;

Module-scope state:

  • pendingSettings: VaultSettings | null — draft, initialized from state.vaultSettings, mutated by the screen.
  • teardown() exported; removes any active key handler.

Render body

<div class="pad">
  <div class="settings-header">
    <button class="btn" id="back-btn">← back</button>
    <h3>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">...</select>
    </div>
    <div class="settings-row">
      <span class="settings-row__label">field history</span>
      <select id="history-retention">...</select>
    </div>
  </div>

  <div class="settings-section">
    <div class="settings-section__title">generator</div>
    <p class="gen-preview-line">{humanSummary(pending.generator_defaults)}</p>
    <button class="btn" id="configure-gen">configure ▾</button>
  </div>

  <div class="settings-section">
    <div class="settings-section__title">autofill origins</div>
    {if empty: <p class="muted">No origins acknowledged yet.</p>}
    {else: sorted ack rows with revoke buttons}
  </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>

Retention dropdown semantics

retentionSelectOptions(kind: 'trash' | 'history'):

  • Trash: Forever, 7 days, 30 days, 60 days, 90 days, 180 days, 365 days, custom….
  • History: Forever, Last 3, Last 5, Last 10, 30 days, 90 days, 365 days, custom….

retentionToSelectValue(r) maps a TrashRetention / HistoryRetention union to one of those option labels (falling back to custom… if it's an N that doesn't match a preset).

selectValueToRetention(kind, label) goes the other way. For custom…, prompt() the user for a number + unit.

Generator-default preview

humanSummary(req: GeneratorRequest): string:

  • Random: "Random, {length} chars, {classes joined with +}, {symbolCharset label}".
  • BIP39: "BIP39, {word_count} words, {separator label}-separated, {capitalization}".

Clicking "configure ▾" opens the generator popover (openGeneratorPopover) with onPicked: () => {} (no-op — the user's intent here is "save as default", not "insert into a field"). On popover close (after save-as-default or cancel), refresh state.vaultSettings via a get_vault_settings round-trip and re-render the settings screen. (The popover's "save as default" already calls update_vault_settings itself.)

Origin-ack list

Sorted by Object.entries(acks).sort(([, a], [, b]) => b - a) (most recent first).

Each row:

<div class="ack-row">
  <span class="ack-row__host">github.com</span>
  <span class="ack-row__meta">acked 3d ago</span>
  <button class="ack-row__revoke" data-host="github.com">revoke</button>
</div>

Revoke handler: delete pending.autofill_origin_acks[host]; rerender(); markDirty();.

Save / discard

markDirty() enables the save button. save sends update_vault_settings with pending; on success, updates state.vaultSettings + state.generatorDefaults and navigates back to the list. discard just navigates back.

Tests

__tests__/settings-vault.test.ts:

  • Render with seeded state.vaultSettings — correct retention labels shown.
  • Change trash-retention select → pending updated; save button enabled.
  • Click revoke on an ack → pending.autofill_origin_acks loses that key; save button enabled.
  • Save → update_vault_settings called with pending; navigates back.
  • Discard → no message sent; navigates back.

CSS

Additions in popup/styles.css:

.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.settings-header h3 { margin: 0; font-size: 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: flex; justify-content: space-between; 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;
}
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }

Testing

Rust

No Rust changes. cargo test --workspace stays green (155 tests from β₁).

Vitest

Existing 84 tests stay green. New tests:

  • types/__tests__/sections-render.test.ts — ~5 tests.
  • types/__tests__/sections-edit.test.ts (or per-type variants as appropriate) — ~5 tests.
  • __tests__/generator-popover.test.ts — ~7 tests.
  • router/__tests__/router.test.ts (extensions) — ~4 tests.
  • __tests__/settings-vault.test.ts — ~5 tests.

Target post-β₂: ~110 tests.

Manual matrix

  1. Add a Login item; in the form's 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. Edit the same item; remove one field; add a text field; save; detail reflects all three changes.
  3. Click ⚙ → vault settings; change trash retention to 7 days; save; reload → still 7 days.
  4. In vault settings, click "configure ▾" on the generator preview; change kind to BIP39; save as default; close popover; preview shows BIP39 summary. Reload → still BIP39.
  5. Back on Login form, click "gen" → popover opens with BIP39 defaults (inherited from settings).
  6. "use this value" on the popover fills the password field with a BIP39 phrase.
  7. Revoke an origin ack; save; attempt autofill on that site → requires-ack flow re-triggers (per α's content-callable handler).
  8. Kind toggle mid-popover switches Random ↔ BIP39; preview refreshes; request shape correct.

Acceptance

  • cargo test --workspace green.
  • bun run test green (~110 tests).
  • bun run build:all green for Chrome + Firefox.
  • git grep -n '@ts-nocheck' extension/src/ → 0.
  • git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ | grep -v document → 0.
  • Manual matrix 8 steps pass on both browsers.

Open questions deferred to plan

  • generate_passphrase message type: α shipped generate_password; if the message union lacks generate_passphrase, add it in Slice 4 alongside the vault-settings messages. The SW router just needs an additional case mirroring generate_password.
  • Custom-field label blanks: what happens when a field has an empty label? Options: (a) reject at save time; (b) allow and render as "(unnamed)". Plan ships (b) — no UX friction; render the value row with the row's label span empty.
  • Retention custom…: is the prompt() acceptable UX, or should it be an inline number + unit input? Plan ships prompt() (matches existing rename-section UX); can polish in a later pass.
  • Deep-equal check for save-button enable: JSON.stringify(a) === JSON.stringify(b) is cheap and sufficient for the VaultSettings shape (no Map/Set/Date keys). Avoids a util dependency.