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) <noreply@anthropic.com>
34 KiB
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.sectionsrendered below typed rows via a newrenderSections(item, idPrefix)helper infields.ts. Sections with ≥1 field render a header (named) or thin separator (anonymous). Fields of kindtextrender viarenderRow;password/concealedviarenderConcealedRowwith 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 viaprompt(); clear to make anonymous). Save packssectionsDraftinto the outgoingItem.sections. -
FieldKind support:
text,password,concealedonly.Url/Email/Phone/Date/MonthYear/Totp/Reference/Multilineall 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
fieldsarray; new sections append toitem.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.tsscreen 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
pendingSettingsdiffers fromvaultSettings.
- Trash retention (
-
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 viagenerate_password/generate_passphrasemessage 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 fullVaultSettings.update_vault_settings→ writes fullVaultSettings. Both added toPOPUP_ONLY_TYPES; not inSETUP_ALLOWED. Router test matrix grows by 4 cases (accept from popup × 2, reject from content × 2). -
Teardown integration: every type module's
teardown()gainscloseGeneratorPopover(). The collapsible disclosure's expanded-state (sectionsExpanded: boolean) is module-scope and reset byteardown().
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
Referencepointers (requires attachment picker).
Architecture
Data flow additions
-
Custom fields: already present end-to-end — the Rust core's
Item.sections: Vec<Section>+Section.fields: Vec<Field>+Field.value: FieldValuedata model is complete. β₁'s save paths already passsections: existing?.sections ?? []through. β₂ just grows the UI to produce and consume that shape. No SW message changes. -
Vault settings: α plumbed
fetchAndDecryptSettings/encryptAndWriteSettingsthroughservice-worker/vault.tsfor the autofill origin-ack writes. β₂ exposes the fullVaultSettingsobject via two new popup-only messages. No new Rust or WASM work. -
Generator popover: already has all the plumbing it needs — α's
generate_password/generate_passphrasemessages accept an arbitraryGeneratorRequestand route to the WASM layer. β₂ just wires a UI.
Module boundaries
popup/components/
fields.ts (extended) — + renderSections, renderSectionsEditor,
wireSectionsEditor, generateFieldId
generator-popover.ts (new) — openGeneratorPopover, closeGeneratorPopover
settings-vault.ts (new) — renderVaultSettings
item-list.ts (edit) — ⚙ toolbar button → device/vault picker
types/login.ts (edit) — + sections tail in renderDetail;
+ disclosure in renderForm;
+ generator popover wire on "gen" button;
+ closeGeneratorPopover in teardown
types/{secure-note,identity,card,key,totp}.ts (edit) — same integration pattern
service-worker/
router/popup-only.ts (edit) — + get_vault_settings, update_vault_settings
shared/
messages.ts (edit) — + 2 new PopupMessage variants, added to POPUP_ONLY_TYPES
types.ts (unchanged)
popup/popup.ts (edit) — + vaultSettings + generatorDefaults in PopupState;
+ fetch after unlock; + settings-vault view route
PopupState additions
vaultSettings: VaultSettings | null; // cached on unlock; refreshed on save
generatorDefaults: GeneratorRequest | null; // derived from vaultSettings.generator_defaults
view: 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
The 'settings-vault' view routes to the new renderVaultSettings.
Slice 1 — Custom-fields detail rendering
fields.ts#renderSections
export function renderSections(item: Item, idPrefix: string): string;
- Walks
item.sections. For each section with ≥1 field:- If
section.nametruthy: 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).
- If
Per-type integration
Every type module's renderDetail gets a call to renderSections between typed rows and action buttons:
app.innerHTML = `
<div class="pad">
${/* signature block + typed rows */}
${renderSections(item, '<type>')} // ← added
${/* form-actions */}
</div>
`;
wireFieldHandlers(app) call already at the bottom of each type module picks up the new reveal/copy buttons in custom-field rows.
Tests
types/__tests__/sections-render.test.ts:
- Empty
item.sections→renderSectionsreturns 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
datefield from the CLI) → silently skipped, no error.
CSS
.section-header {
margin-top: 14px;
margin-bottom: 4px;
padding-top: 10px;
border-top: 1px solid #21262d;
color: #8b949e;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.section-separator { margin: 10px 0 4px; border: 0; border-top: 1px solid #21262d; }
Slice 2 — Custom-fields edit rendering
fields.ts#renderSectionsEditor + wireSectionsEditor
export function renderSectionsEditor(sections: Section[], expanded: boolean): string;
/// Wire handlers for the editor's interactive elements. Mutations to
/// `sectionsDraft` are reflected by `rerender()` — callers implement
/// rerender by re-running `renderSectionsEditor` + inserting it back
/// into the disclosure's body element.
export function wireSectionsEditor(
scope: HTMLElement,
sectionsDraft: Section[],
rerender: () => void,
): void;
Layout (expanded state)
▾ custom sections & fields (2 sections, 5 fields)
── recovery codes ────── [rename] [× remove section]
[label_________] [value_________________] [×]
[label_________] [value_________________] [×]
[+ text] [+ password] [+ concealed]
── (anonymous) ───────── [rename] [× remove section]
[label_________] [value_________________] [×]
[+ text] [+ password] [+ concealed]
[+ add section]
generateFieldId
/// Client-side 16-char hex FieldId. Uses crypto.getRandomValues for
/// 8 random bytes; matches the wire-format requirement. No SW round-trip.
export function generateFieldId(): string {
const bytes = new Uint8Array(8);
crypto.getRandomValues(bytes);
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
}
Mutations
-
Add section:
sectionsDraft.push({ name: undefined, fields: [] }); rerender. -
Rename section:
prompt('Section name (empty for none):', section.name ?? ''); setsectionsDraft[i].name = result.trim() || undefined; rerender. -
Remove section:
confirm('Remove section ...?');sectionsDraft.splice(i, 1); rerender. -
Add field (kind K):
sectionsDraft[i].fields.push(makeField(K)); rerender. Helper:function makeField(kind: 'text' | 'password' | 'concealed'): Field { const hidden = kind !== 'text'; return { id: generateFieldId(), label: 'new field', kind, value: { kind, value: '' } as FieldValue, hidden_by_default: hidden, }; } -
Remove field:
sectionsDraft[i].fields.splice(j, 1); rerender. -
Edit field label:
inputevent on label input mutatessectionsDraft[i].fields[j].labelin place. No rerender (would steal focus). -
Edit field value:
inputevent mutatessectionsDraft[i].fields[j].value.valuein place. No rerender.
Per-type form integration
Each of the 6 type modules (types/<x>.ts):
- At the top of
renderForm, initialize a localsectionsDraft: Section[] = existing?.sections.map(deepClone) ?? [](deep clone so cancel doesn't mutate the pre-existing item). - Add
let sectionsExpanded = false;at module scope, reset byteardown(). - Insert
${renderSectionsEditor(sectionsDraft, sectionsExpanded)}in the form HTML, just before<div class="form-actions">. - After
app.innerHTML = ..., callwireSectionsEditor(app, sectionsDraft, rerender)wherererenderreplaces the disclosure subtree's innerHTML with a freshrenderSectionsEditor(sectionsDraft, sectionsExpanded). - In save, replace
sections: existing?.sections ?? []withsections: 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_itemmessage'sitem.sectionsmatches the draft structure. - Round-trip on edit mode: pre-populate
existingwith sections, open form, confirm sections render expanded (since count > 0), add a field, save → outgoing sections has the new field appended.
CSS additions
.disclosure {
border-top: 1px solid #21262d;
margin-top: 14px;
padding-top: 10px;
}
.disclosure__toggle {
background: transparent; border: 0; color: #58a6ff;
cursor: pointer; font-size: 12px; padding: 0;
font-family: inherit;
}
.disclosure[data-expanded="false"] .disclosure__body { display: none; }
.section-editor__head {
display: flex; align-items: baseline; gap: 8px;
margin-top: 10px; margin-bottom: 4px;
font-size: 11px;
}
.section-editor__head .name { color: #c9d1d9; font-weight: 600; }
.section-editor__head .name.anon { color: #8b949e; font-style: italic; }
.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; }
.section-editor__head .actions button { background: transparent; border: 0; color: inherit; cursor: pointer; padding: 0; margin-left: 8px; }
.section-editor__field {
display: grid; grid-template-columns: 120px 1fr auto;
gap: 4px; margin-bottom: 4px; font-size: 11px;
}
.section-editor__field input {
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px;
}
.section-editor__field .delete-field {
background: transparent; border: 0; color: #f85149; cursor: pointer;
font-size: 14px; padding: 0 4px;
}
.section-editor__add {
display: flex; gap: 6px; margin-top: 6px;
}
.section-editor__add button {
background: transparent; border: 1px solid #30363d; color: #8b949e;
padding: 2px 10px; border-radius: 3px; cursor: pointer; font-size: 10px;
font-family: inherit;
}
.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; }
.disclosure__body .add-section {
margin-top: 12px; background: transparent;
border: 1px dashed #30363d; color: #8b949e;
padding: 6px 10px; border-radius: 4px; cursor: pointer;
width: 100%; font-size: 11px; font-family: inherit;
}
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
Slice 3 — Vault-settings SW plumbing
Messages
shared/messages.ts — add to PopupMessage:
| { type: 'get_vault_settings' }
| { type: 'update_vault_settings'; settings: VaultSettings }
Add both to POPUP_ONLY_TYPES. NOT in SETUP_ALLOWED.
Add:
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
data: { settings: VaultSettings };
}
Handlers (router/popup-only.ts)
case 'get_vault_settings': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
return { ok: true, data: { settings } };
}
case 'update_vault_settings': {
const handle = session.getCurrent();
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
await vault.encryptAndWriteSettings(
state.gitHost, handle, msg.settings,
'settings: update vault-level config',
);
return { ok: true };
}
Router tests
router/__tests__/router.test.ts (+4 cases):
get_vault_settingsaccepted from popup (mockfetchAndDecryptSettings→ returns aVaultSettings); response shape matchesVaultSettingsResponse.get_vault_settingsrejected from content →unauthorized_sender.update_vault_settingsaccepted from popup; callsencryptAndWriteSettings.update_vault_settingsrejected from setup tab (not in SETUP_ALLOWED).
Popup init
popup.ts#init, after a successful unlock-is-active branch:
const vsResp = await sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
const vs = (vsResp.data as { settings: VaultSettings }).settings;
currentState.vaultSettings = vs;
currentState.generatorDefaults = vs.generator_defaults as GeneratorRequest;
}
Fetched once at popup open; refreshed after any update_vault_settings success. The "fetch on open" cost is one extra round-trip over α — acceptable given vault-settings drives multiple screens.
generate_passphrase message (add if missing)
The α plan lists generate_password as a popup-only message. The generator popover (Slice 4) also needs generate_passphrase for BIP39 preview. Check shared/messages.ts; if absent, add:
| { type: 'generate_passphrase'; request: GeneratorRequest }
Add to POPUP_ONLY_TYPES. The SW handler mirrors generate_password but calls the generate_passphrase WASM function. One new case in router/popup-only.ts.
Slice 4 — Generator inline popover
popup/components/generator-popover.ts
export function openGeneratorPopover(opts: {
anchor: HTMLElement;
initial: GeneratorRequest;
onPicked: (value: string) => void;
}): void;
export function closeGeneratorPopover(): void;
Module-scope state:
let activePopover: {
host: HTMLElement;
onDismiss: () => void;
} | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
Layout (Random kind)
┌─ generate ────────────────── ✕ ┐
│ │
│ kind: [● Random] [○ BIP39] │
│ │
│ length: [════●═══════] 20 │
│ │
│ ☑ lowercase ☑ digits │
│ ☑ uppercase ☑ symbols │
│ │
│ symbols: [● safe] [○ extended] │
│ │
│ ─ preview ──────────────────── │
│ Kj7%pW@2xNq!8rMvT [↻] │
│ │
│ [reset to defaults] │
│ [save as default] │
│ [cancel] [use this value] │
└─────────────────────────────────┘
Layout (BIP39 kind)
┌─ generate ────────────────── ✕ ┐
│ kind: [○ Random] [● BIP39] │
│ │
│ words: [═══●════════] 5 │
│ │
│ separator: [space] [-] [_] [.] [:]
│ │
│ capitalization: │
│ [● lower] [upper] [first] [title]
│ │
│ ─ preview ──────────────────── │
│ correct horse battery staple parapet
│ │
│ [reset to defaults] │
│ [save as default] │
│ [cancel] [use this value] │
└─────────────────────────────────┘
Request construction
function buildRequest(kind: 'random' | 'bip39', knobs: UiKnobs): GeneratorRequest {
if (kind === 'random') {
return {
kind: 'random',
length: knobs.length,
classes: {
lower: knobs.lower, upper: knobs.upper,
digits: knobs.digits, symbols: knobs.symbols,
},
symbol_charset:
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
{ kind: 'custom', value: knobs.customSymbols ?? '' },
};
}
return {
kind: 'bip39',
word_count: knobs.wordCount,
separator: knobs.separator,
capitalization: knobs.capitalization,
};
}
Preview refresh
On any knob change, debounced 150ms:
async function refreshPreview(): Promise<void> {
const request = buildRequest(uiKind, uiKnobs);
const msg = uiKind === 'random'
? { type: 'generate_password' as const, request }
: { type: 'generate_passphrase' as const, request };
const resp = await sendMessage(msg);
if (resp.ok) {
const data = resp.data as { password?: string; passphrase?: string };
const previewEl = activePopover?.host.querySelector('.gen-preview__value');
if (previewEl) previewEl.textContent = data.password ?? data.passphrase ?? '';
}
}
Note: α added generate_password but generate_passphrase may need to be added (check α's messages.ts). If not present, add it alongside generate_password in slice 4's scope (router handler already accepts a request_json → WASM generate_passphrase).
Validation
"use this value" button disabled when:
- Random kind and no char-class checked (
!lower && !upper && !digits && !symbols). - BIP39 kind never disabled (always valid — word count ≥ 3).
Visual cue: when disabled, button is dimmed + a <p class="gen-validation">pick at least one character class</p> renders below.
Actions
- use this value:
onPicked(currentPreview); close();. Host field's setter wraps this (e.g.,pw.value = value; pw.type = 'text';for the Login form). - save as default: fetch the full
vaultSettingsviasendMessage({ type: 'get_vault_settings' }); write{ ...vaultSettings, generator_defaults: currentRequest }viaupdate_vault_settings. On success: updatestate.vaultSettings+state.generatorDefaults; flash "saved" on the button for 1.5s; do NOT close. - reset to defaults: reset UI knobs to
state.generatorDefaults ?? DEFAULT_PASSWORD_REQUEST; refresh preview. - cancel / Escape / outside-click: close without callback.
Teardown wiring
Every type module's existing teardown() gains:
closeGeneratorPopover();
So navigation or re-rendering always cleans up the popover.
Tests
__tests__/generator-popover.test.ts (mocks sendMessage):
- Open with default initial → renders Random kind, shows
length=20, all 4 classes checked, safe_only. - BIP39 toggle → switches knobs to word-count / separator / capitalization;
sendMessagecalled withgenerate_passphrase. - Length slider change → debounced
generate_passwordcall with updatedlength. - "use this value" →
onPickedcalled with current preview string; popover closes. - "save as default" →
update_vault_settingscalled 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 theViewunion. - Add the render-switch case pointing at
renderVaultSettings. - Toolbar ⚙ button on
item-list.tsbecomes a tiny picker (render inline, same pattern as the "+ New" picker):
⚙
├ device settings → navigate('settings')
└ vault settings → navigate('settings-vault')
popup/components/settings-vault.ts
export function renderVaultSettings(app: HTMLElement): void;
Module-scope state:
pendingSettings: VaultSettings | null— draft, initialized fromstate.vaultSettings, mutated by the screen.teardown()exported; removes any active key handler.
Render body
<div class="pad">
<div class="settings-header">
<button class="btn" id="back-btn">← back</button>
<h3>vault settings</h3>
</div>
<div class="settings-section">
<div class="settings-section__title">retention</div>
<div class="settings-row">
<span class="settings-row__label">trash</span>
<select id="trash-retention">...</select>
</div>
<div class="settings-row">
<span class="settings-row__label">field history</span>
<select id="history-retention">...</select>
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">generator</div>
<p class="gen-preview-line">{humanSummary(pending.generator_defaults)}</p>
<button class="btn" id="configure-gen">configure ▾</button>
</div>
<div class="settings-section">
<div class="settings-section__title">autofill origins</div>
{if empty: <p class="muted">No origins acknowledged yet.</p>}
{else: sorted ack rows with revoke buttons}
</div>
<div class="settings-footer">
<button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
</div>
</div>
Retention dropdown semantics
retentionSelectOptions(kind: 'trash' | 'history'):
- Trash:
Forever,7 days,30 days,60 days,90 days,180 days,365 days,custom…. - History:
Forever,Last 3,Last 5,Last 10,30 days,90 days,365 days,custom….
retentionToSelectValue(r) maps a TrashRetention / HistoryRetention union to one of those option labels (falling back to custom… if it's an N that doesn't match a preset).
selectValueToRetention(kind, label) goes the other way. For custom…, prompt() the user for a number + unit.
Generator-default preview
humanSummary(req: GeneratorRequest): string:
- Random:
"Random, {length} chars, {classes joined with +}, {symbolCharset label}". - BIP39:
"BIP39, {word_count} words, {separator label}-separated, {capitalization}".
Clicking "configure ▾" opens the generator popover (openGeneratorPopover) with onPicked: () => {} (no-op — the user's intent here is "save as default", not "insert into a field"). On popover close (after save-as-default or cancel), refresh state.vaultSettings via a get_vault_settings round-trip and re-render the settings screen. (The popover's "save as default" already calls update_vault_settings itself.)
Origin-ack list
Sorted by Object.entries(acks).sort(([, a], [, b]) => b - a) (most recent first).
Each row:
<div class="ack-row">
<span class="ack-row__host">github.com</span>
<span class="ack-row__meta">acked 3d ago</span>
<button class="ack-row__revoke" data-host="github.com">revoke</button>
</div>
Revoke handler: delete pending.autofill_origin_acks[host]; rerender(); markDirty();.
Save / discard
markDirty() enables the save button. save sends update_vault_settings with pending; on success, updates state.vaultSettings + state.generatorDefaults and navigates back to the list. discard just navigates back.
Tests
__tests__/settings-vault.test.ts:
- Render with seeded
state.vaultSettings— correct retention labels shown. - Change trash-retention select →
pendingupdated; save button enabled. - Click revoke on an ack →
pending.autofill_origin_acksloses that key; save button enabled. - Save →
update_vault_settingscalled withpending; navigates back. - Discard → no message sent; navigates back.
CSS
Additions in popup/styles.css:
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.settings-header h3 { margin: 0; font-size: 14px; }
.settings-section {
margin-top: 14px; padding-top: 10px;
border-top: 1px solid #21262d;
}
.settings-section__title {
color: #8b949e; font-size: 10px;
text-transform: uppercase; letter-spacing: 0.08em;
margin-bottom: 6px;
}
.settings-row {
display: grid; grid-template-columns: 110px 1fr;
gap: 6px 10px; align-items: center;
margin: 4px 0; font-size: 12px;
}
.settings-row__label { color: #8b949e; }
.settings-row select {
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px;
}
.gen-preview-line {
margin: 0 0 6px; font-size: 11px; color: #c9d1d9;
font-family: "SF Mono", "JetBrains Mono", monospace;
}
.ack-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0; font-size: 11px;
border-bottom: 1px solid #161b22;
}
.ack-row__host { color: #c9d1d9; font-family: monospace; }
.ack-row__meta { color: #6e7681; font-size: 10px; }
.ack-row__revoke {
background: transparent; border: 0; color: #f85149;
cursor: pointer; font-size: 10px;
}
.settings-footer {
display: flex; justify-content: flex-end; gap: 6px;
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
}
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
Testing
Rust
No Rust changes. cargo test --workspace stays green (155 tests from β₁).
Vitest
Existing 84 tests stay green. New tests:
types/__tests__/sections-render.test.ts— ~5 tests.types/__tests__/sections-edit.test.ts(or per-type variants as appropriate) — ~5 tests.__tests__/generator-popover.test.ts— ~7 tests.router/__tests__/router.test.ts(extensions) — ~4 tests.__tests__/settings-vault.test.ts— ~5 tests.
Target post-β₂: ~110 tests.
Manual matrix
- 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.
- Edit the same item; remove one field; add a text field; save; detail reflects all three changes.
- Click ⚙ → vault settings; change trash retention to
7 days; save; reload → still7 days. - 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.
- Back on Login form, click "gen" → popover opens with BIP39 defaults (inherited from settings).
- "use this value" on the popover fills the password field with a BIP39 phrase.
- Revoke an origin ack; save; attempt autofill on that site → requires-ack flow re-triggers (per α's content-callable handler).
- Kind toggle mid-popover switches Random ↔ BIP39; preview refreshes; request shape correct.
Acceptance
cargo test --workspacegreen.bun run testgreen (~110 tests).bun run build:allgreen 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_passphrasemessage type: α shippedgenerate_password; if the message union lacksgenerate_passphrase, add it in Slice 4 alongside the vault-settings messages. The SW router just needs an additional case mirroringgenerate_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 theprompt()acceptable UX, or should it be an inline number + unit input? Plan shipsprompt()(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 theVaultSettingsshape (no Map/Set/Date keys). Avoids a util dependency.