# 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.fields: Vec` + `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 `
{escaped name}
` - Else (anonymous): emit `
` - 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 = `
${/* signature block + typed rows */} ${renderSections(item, '')} // ← added ${/* form-actions */}
`; ``` `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/.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 `
`. 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 { 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 | 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 { 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 `

pick at least one character class

` 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

vault settings

retention
trash
field history
generator

{humanSummary(pending.generator_defaults)}

autofill origins
{if empty:

No origins acknowledged yet.

} {else: sorted ack rows with revoke buttons}
``` ### 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
github.com acked 3d ago
``` 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.