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

732 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Relicario — Extension 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.