From 62112f50f97fa427c91abf1326e87c0a4fcab77a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Thu, 23 Apr 2026 23:59:14 -0400 Subject: [PATCH] =?UTF-8?q?docs:=20Plan=201C-=CE=B2=E2=82=82=20(custom=20f?= =?UTF-8?q?ields=20+=20settings=20+=20generator=20UI)=20design=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third β sub-plan. Adds cross-cutting UI surfaces on top of β₁'s typed- item forms: - Custom-fields editor: collapsible disclosure in edit forms; sections + fields of kind Text/Password/Concealed (other 8 FieldKinds deferred). No reordering. Always-visible below typed rows in detail mode. - Full VaultSettings view: trash retention, field-history retention, generator defaults (preview + "configure" link to the popover), autofill origin-ack revoke. Skip attachment caps (γ concern). - Inline generator popover: invoked at every "gen" button. Random/BIP39 kind toggle, length/word-count slider, charset checkboxes. Actions: use this value / save as default / reset / cancel. Shared with the Settings screen's "configure ▾" button. - Two new popup-only messages: get_vault_settings / update_vault_settings (thin wrappers around α's fetchAndDecryptSettings / encryptAndWrite- Settings). NOT in SETUP_ALLOWED. - generate_passphrase message added if missing for BIP39 previews. Five-slice sequencing in execution order: 1. Custom-fields detail rendering (read-only) 2. Custom-fields edit rendering (disclosure + add/remove) 3. Vault-settings SW plumbing (+ generate_passphrase if needed) 4. Generator inline popover 5. Settings view + origin-ack revoke + default wiring Slice 3 intentionally lands before Slice 4 so the popover's "save as default" action is fully functional the moment it ships. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-22-relicario-extension-1c-beta2-design.md | 731 ++++++++++++++++++ 1 file changed, 731 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta2-design.md diff --git a/docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta2-design.md b/docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta2-design.md new file mode 100644 index 0000000..7ad610b --- /dev/null +++ b/docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta2-design.md @@ -0,0 +1,731 @@ +# 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.