# 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

```ts
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`

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

```ts
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.sections` → `renderSections` 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

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

```ts
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`

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

  ```ts
  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

```css
.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`:
```ts
  | { type: 'get_vault_settings' }
  | { type: 'update_vault_settings'; settings: VaultSettings }
```

Add both to `POPUP_ONLY_TYPES`. NOT in `SETUP_ALLOWED`.

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

### Handlers (`router/popup-only.ts`)

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

```ts
  | { 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`

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

export function closeGeneratorPopover(): void;
```

Module-scope state:

```ts
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

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

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

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

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