Brand name uses capital R in user-facing text — extension UI strings, CLI clap help / descriptions / error prose, markdown docs. Lowercase preserved for the binary command, crate names, npm package, file paths, env vars, and code identifiers. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
732 lines
34 KiB
Markdown
732 lines
34 KiB
Markdown
# Relicario — Extension Plan 1C-β₂ (Custom Fields + Settings + Generator UI) Design
|
||
|
||
Third of three β sub-plans porting the extension to the typed-item core. 1C-α shipped the security architecture + Login parity; 1C-β₁ added the 5 remaining typed-item forms; **1C-β₂** (this spec) adds the cross-cutting UI surfaces: custom fields editor, full vault-settings view, and an inline generator popover.
|
||
|
||
Reference specs: `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (α, commits `a1d733d` + `ad6d8af`), `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md` (β₁, commit `1b51b7d`). Both implementations merged to main: α at `2b83105` (tag `plan-1c-alpha-complete`), β₁ at `81fbe13` (tag `plan-1c-beta1-complete`).
|
||
|
||
## Plan 1C decomposition (final shape)
|
||
|
||
| Sub-plan | Status | Scope |
|
||
|---|---|---|
|
||
| 1C-α | shipped 2026-04-22 | WASM rebuild, typed-item shared TS types, SessionHandle SW, split router with sender checks, closed Shadow DOM content scripts, Login-parity popup, zxcvbn setup gate |
|
||
| 1C-β₁ | shipped 2026-04-22 | 5 remaining typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam-Guard alphabet patch; shared field helpers + Login refactor |
|
||
| **1C-β₂** (this spec) | proposed | Custom-fields editor (Text/Password/Concealed), full VaultSettings view (retention + generator defaults + origin-ack revoke), advanced generator popover |
|
||
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management, attachment caps UI |
|
||
|
||
## Design Decisions (from brainstorming)
|
||
|
||
| Question | Decision | Why |
|
||
|---|---|---|
|
||
| Custom-fields scope | **Tier 1 — Text/Password/Concealed only, no reordering** | The other 8 FieldKinds (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline) each add real UX work; tier 1 covers the "recovery codes, security questions" 90% case. Reordering and additional kinds live in a later polish pass. |
|
||
| VaultSettings scope | **Retention + generator defaults + origin-ack revoke; skip attachment caps** | Attachment caps govern a feature that doesn't ship until γ. Ship the caps UI alongside the feature. |
|
||
| Generator UI location | **Inline popover + Settings preview** | One underlying `GeneratorRequest` config, two entry points. Matches 1Password/Bitwarden. "save as default" in the popover updates Settings without forcing the user to navigate. |
|
||
| Custom-fields edit-view placement | **Collapsible disclosure ("▸ custom sections & fields (N)")** | Most items never grow custom fields; always-visible editor adds clutter for the 90% case. Count-hint on the disclosure gives discoverability without noise. |
|
||
| Sequencing | **5 slices: detail render → edit render → vault-settings SW (+ generate_passphrase if missing) → generator popover → settings view** | Matches β₁'s cadence. SW plumbing lands before the popover so "save as default" is fully functional the moment the popover ships. |
|
||
|
||
## Scope
|
||
|
||
### In
|
||
|
||
- **Custom-fields rendering** (detail view): `Item.sections` rendered below typed rows via a new `renderSections(item, idPrefix)` helper in `fields.ts`. Sections with ≥1 field render a header (named) or thin separator (anonymous). Fields of kind `text` render via `renderRow`; `password`/`concealed` via `renderConcealedRow` with per-section unique IDs.
|
||
|
||
- **Custom-fields editor** (edit view): collapsible disclosure ("▸ custom sections & fields (N)") at the bottom of every type's form. Expanded state shows each section's rename/remove buttons, per-field label + value inputs + `×` delete, and per-section `[+ text] [+ password] [+ concealed]` buttons. A `[+ add section]` button at the bottom. Sections have optional names (rename via `prompt()`; clear to make anonymous). Save packs `sectionsDraft` into the outgoing `Item.sections`.
|
||
|
||
- **FieldKind support**: `text`, `password`, `concealed` only. `Url` / `Email` / `Phone` / `Date` / `MonthYear` / `Totp` / `Reference` / `Multiline` all remain Rust-core-only (the data model supports them; the popup doesn't render editors for them in β₂).
|
||
|
||
- **No reordering**: new fields append to their section's `fields` array; new sections append to `item.sections`. Rendering preserves array order. A future polish pass can add up/down arrows or drag handles.
|
||
|
||
- **Full VaultSettings view**: new `popup/components/settings-vault.ts` screen wired to the ⚙ toolbar button (now a tiny picker: device / vault). Covers:
|
||
- Trash retention (`Days(N)` / `Forever`) via a preset dropdown (Forever / 7 / 30 / 60 / 90 / 180 / 365 / custom days).
|
||
- Field-history retention (`LastN(N)` / `Days(N)` / `Forever`) via a preset dropdown (Forever / Last 3 / Last 5 / Last 10 / 30 days / 90 days / 365 days / custom).
|
||
- Generator-default preview with a "configure ▾" button that opens the same generator popover used at form "gen" sites; "save as default" closes the loop.
|
||
- Origin-ack list (`autofill_origin_acks`) sorted by most-recent first, with per-host revoke buttons.
|
||
- Save-changes / discard buttons; save disabled until `pendingSettings` differs from `vaultSettings`.
|
||
|
||
- **Advanced generator popover**: new `popup/components/generator-popover.ts`. Anchored to the "gen" button; positioned absolutely below. Kind toggle (Random / BIP39). Random knobs: length slider (8-64), 4 char-class checkboxes, symbol-charset toggle (safe_only / extended / custom). BIP39 knobs: word count slider (3-12), separator chip picker (space / `-` / `_` / `.` / `:`), capitalization picker (lower / upper / first-of-each / title). Live preview via `generate_password` / `generate_passphrase` message on 150ms debounce. Four action buttons: `reset to defaults`, `save as default`, `cancel`, `use this value`. Validation: "use this value" disabled when no char class selected for Random kind.
|
||
|
||
- **New popup-only messages**: `get_vault_settings` → returns full `VaultSettings`. `update_vault_settings` → writes full `VaultSettings`. Both added to `POPUP_ONLY_TYPES`; not in `SETUP_ALLOWED`. Router test matrix grows by 4 cases (accept from popup × 2, reject from content × 2).
|
||
|
||
- **Teardown integration**: every type module's `teardown()` gains `closeGeneratorPopover()`. The collapsible disclosure's expanded-state (`sectionsExpanded: boolean`) is module-scope and reset by `teardown()`.
|
||
|
||
### Out (→ γ / later)
|
||
|
||
- Reordering (sections or fields-within-section).
|
||
- Other FieldKind variants (Url/Email/Phone/Date/MonthYear/Totp/Reference/Multiline).
|
||
- Attachment caps UI (γ concern, bundled with attachments).
|
||
- Bulk custom-field operations (delete-many, template, import-from-CSV).
|
||
- Per-type section templates (e.g., Card auto-creates a "billing address" section).
|
||
- Item-to-item `Reference` pointers (requires attachment picker).
|
||
|
||
## Architecture
|
||
|
||
### Data flow additions
|
||
|
||
1. **Custom fields**: already present end-to-end — the Rust core's `Item.sections: Vec<Section>` + `Section.fields: Vec<Field>` + `Field.value: FieldValue` data model is complete. β₁'s save paths already pass `sections: existing?.sections ?? []` through. β₂ just grows the UI to produce and consume that shape. No SW message changes.
|
||
|
||
2. **Vault settings**: α plumbed `fetchAndDecryptSettings` / `encryptAndWriteSettings` through `service-worker/vault.ts` for the autofill origin-ack writes. β₂ exposes the full `VaultSettings` object via two new popup-only messages. No new Rust or WASM work.
|
||
|
||
3. **Generator popover**: already has all the plumbing it needs — α's `generate_password` / `generate_passphrase` messages accept an arbitrary `GeneratorRequest` and route to the WASM layer. β₂ just wires a UI.
|
||
|
||
### Module boundaries
|
||
|
||
```
|
||
popup/components/
|
||
fields.ts (extended) — + renderSections, renderSectionsEditor,
|
||
wireSectionsEditor, generateFieldId
|
||
generator-popover.ts (new) — openGeneratorPopover, closeGeneratorPopover
|
||
settings-vault.ts (new) — renderVaultSettings
|
||
item-list.ts (edit) — ⚙ toolbar button → device/vault picker
|
||
types/login.ts (edit) — + sections tail in renderDetail;
|
||
+ disclosure in renderForm;
|
||
+ generator popover wire on "gen" button;
|
||
+ closeGeneratorPopover in teardown
|
||
types/{secure-note,identity,card,key,totp}.ts (edit) — same integration pattern
|
||
|
||
service-worker/
|
||
router/popup-only.ts (edit) — + get_vault_settings, update_vault_settings
|
||
|
||
shared/
|
||
messages.ts (edit) — + 2 new PopupMessage variants, added to POPUP_ONLY_TYPES
|
||
types.ts (unchanged)
|
||
|
||
popup/popup.ts (edit) — + vaultSettings + generatorDefaults in PopupState;
|
||
+ fetch after unlock; + settings-vault view route
|
||
```
|
||
|
||
### PopupState additions
|
||
|
||
```ts
|
||
vaultSettings: VaultSettings | null; // cached on unlock; refreshed on save
|
||
generatorDefaults: GeneratorRequest | null; // derived from vaultSettings.generator_defaults
|
||
view: 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault';
|
||
```
|
||
|
||
The `'settings-vault'` view routes to the new `renderVaultSettings`.
|
||
|
||
## Slice 1 — Custom-fields detail rendering
|
||
|
||
### `fields.ts#renderSections`
|
||
|
||
```ts
|
||
export function renderSections(item: Item, idPrefix: string): string;
|
||
```
|
||
|
||
- Walks `item.sections`. For each section with ≥1 field:
|
||
- If `section.name` truthy: emit `<div class="section-header">{escaped name}</div>`
|
||
- Else (anonymous): emit `<hr class="section-separator">`
|
||
- For each field:
|
||
- `field.value.kind === 'text'` → `renderRow({ label: field.label, value: field.value.value, copyable: true })`
|
||
- `field.value.kind === 'password'` / `'concealed'` → `renderConcealedRow({ id: `${idPrefix}-s${sectionIdx}-f${fieldIdx}`, label: field.label, value: field.value.value })`
|
||
- Other kinds: silently skip in β₂ (the Rust core may carry other-kind fields from the CLI; we render what we support).
|
||
|
||
### Per-type integration
|
||
|
||
Every type module's `renderDetail` gets a call to `renderSections` between typed rows and action buttons:
|
||
|
||
```ts
|
||
app.innerHTML = `
|
||
<div class="pad">
|
||
${/* signature block + typed rows */}
|
||
${renderSections(item, '<type>')} // ← added
|
||
${/* form-actions */}
|
||
</div>
|
||
`;
|
||
```
|
||
|
||
`wireFieldHandlers(app)` call already at the bottom of each type module picks up the new reveal/copy buttons in custom-field rows.
|
||
|
||
### Tests
|
||
|
||
`types/__tests__/sections-render.test.ts`:
|
||
- Empty `item.sections` → `renderSections` returns empty string.
|
||
- One named section with 2 text fields → contains the section name + both field labels + both values as visible text.
|
||
- Mixed text + password fields → password value concealed (not in visible DOM text); has reveal button.
|
||
- Anonymous section → separator HR, no name header.
|
||
- Unsupported kind (e.g., a `date` field from the CLI) → silently skipped, no error.
|
||
|
||
### CSS
|
||
|
||
```css
|
||
.section-header {
|
||
margin-top: 14px;
|
||
margin-bottom: 4px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid #21262d;
|
||
color: #8b949e;
|
||
font-size: 10px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
}
|
||
.section-separator { margin: 10px 0 4px; border: 0; border-top: 1px solid #21262d; }
|
||
```
|
||
|
||
## Slice 2 — Custom-fields edit rendering
|
||
|
||
### `fields.ts#renderSectionsEditor` + `wireSectionsEditor`
|
||
|
||
```ts
|
||
export function renderSectionsEditor(sections: Section[], expanded: boolean): string;
|
||
|
||
/// Wire handlers for the editor's interactive elements. Mutations to
|
||
/// `sectionsDraft` are reflected by `rerender()` — callers implement
|
||
/// rerender by re-running `renderSectionsEditor` + inserting it back
|
||
/// into the disclosure's body element.
|
||
export function wireSectionsEditor(
|
||
scope: HTMLElement,
|
||
sectionsDraft: Section[],
|
||
rerender: () => void,
|
||
): void;
|
||
```
|
||
|
||
### Layout (expanded state)
|
||
|
||
```
|
||
▾ custom sections & fields (2 sections, 5 fields)
|
||
|
||
── recovery codes ────── [rename] [× remove section]
|
||
[label_________] [value_________________] [×]
|
||
[label_________] [value_________________] [×]
|
||
[+ text] [+ password] [+ concealed]
|
||
|
||
── (anonymous) ───────── [rename] [× remove section]
|
||
[label_________] [value_________________] [×]
|
||
[+ text] [+ password] [+ concealed]
|
||
|
||
[+ add section]
|
||
```
|
||
|
||
### `generateFieldId`
|
||
|
||
```ts
|
||
/// Client-side 16-char hex FieldId. Uses crypto.getRandomValues for
|
||
/// 8 random bytes; matches the wire-format requirement. No SW round-trip.
|
||
export function generateFieldId(): string {
|
||
const bytes = new Uint8Array(8);
|
||
crypto.getRandomValues(bytes);
|
||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||
}
|
||
```
|
||
|
||
### Mutations
|
||
|
||
- **Add section**: `sectionsDraft.push({ name: undefined, fields: [] })`; rerender.
|
||
- **Rename section**: `prompt('Section name (empty for none):', section.name ?? '')`; set `sectionsDraft[i].name = result.trim() || undefined`; rerender.
|
||
- **Remove section**: `confirm('Remove section ...?')`; `sectionsDraft.splice(i, 1)`; rerender.
|
||
- **Add field** (kind K): `sectionsDraft[i].fields.push(makeField(K))`; rerender. Helper:
|
||
|
||
```ts
|
||
function makeField(kind: 'text' | 'password' | 'concealed'): Field {
|
||
const hidden = kind !== 'text';
|
||
return {
|
||
id: generateFieldId(),
|
||
label: 'new field',
|
||
kind,
|
||
value: { kind, value: '' } as FieldValue,
|
||
hidden_by_default: hidden,
|
||
};
|
||
}
|
||
```
|
||
|
||
- **Remove field**: `sectionsDraft[i].fields.splice(j, 1)`; rerender.
|
||
- **Edit field label**: `input` event on label input mutates `sectionsDraft[i].fields[j].label` in place. No rerender (would steal focus).
|
||
- **Edit field value**: `input` event mutates `sectionsDraft[i].fields[j].value.value` in place. No rerender.
|
||
|
||
### Per-type form integration
|
||
|
||
Each of the 6 type modules (`types/<x>.ts`):
|
||
|
||
1. At the top of `renderForm`, initialize a local `sectionsDraft: Section[] = existing?.sections.map(deepClone) ?? []` (deep clone so cancel doesn't mutate the pre-existing item).
|
||
2. Add `let sectionsExpanded = false;` at module scope, reset by `teardown()`.
|
||
3. Insert `${renderSectionsEditor(sectionsDraft, sectionsExpanded)}` in the form HTML, just before `<div class="form-actions">`.
|
||
4. After `app.innerHTML = ...`, call `wireSectionsEditor(app, sectionsDraft, rerender)` where `rerender` replaces the disclosure subtree's innerHTML with a fresh `renderSectionsEditor(sectionsDraft, sectionsExpanded)`.
|
||
5. In save, replace `sections: existing?.sections ?? []` with `sections: sectionsDraft`.
|
||
|
||
`deepClone` helper: `JSON.parse(JSON.stringify(existing.sections))` is sufficient for the `Section[]` shape (no class instances, no Date objects, no undefined in positions that need to survive).
|
||
|
||
### Tests
|
||
|
||
`types/__tests__/sections-edit.test.ts`:
|
||
- Open form (add mode), click disclosure toggle → data-expanded flips true.
|
||
- Click "+ add section" → one section appears; its field list is empty.
|
||
- Rename the section via mocked `window.prompt` → section header updates.
|
||
- Click "+ text" → a text field appears with label "new field" and empty value.
|
||
- Edit the label + value inputs → assertions on the in-memory sectionsDraft.
|
||
- Click save → `add_item` message's `item.sections` matches the draft structure.
|
||
- Round-trip on edit mode: pre-populate `existing` with sections, open form, confirm sections render expanded (since count > 0), add a field, save → outgoing sections has the new field appended.
|
||
|
||
### CSS additions
|
||
|
||
```css
|
||
.disclosure {
|
||
border-top: 1px solid #21262d;
|
||
margin-top: 14px;
|
||
padding-top: 10px;
|
||
}
|
||
.disclosure__toggle {
|
||
background: transparent; border: 0; color: #58a6ff;
|
||
cursor: pointer; font-size: 12px; padding: 0;
|
||
font-family: inherit;
|
||
}
|
||
.disclosure[data-expanded="false"] .disclosure__body { display: none; }
|
||
.section-editor__head {
|
||
display: flex; align-items: baseline; gap: 8px;
|
||
margin-top: 10px; margin-bottom: 4px;
|
||
font-size: 11px;
|
||
}
|
||
.section-editor__head .name { color: #c9d1d9; font-weight: 600; }
|
||
.section-editor__head .name.anon { color: #8b949e; font-style: italic; }
|
||
.section-editor__head .actions { color: #8b949e; font-size: 10px; margin-left: auto; }
|
||
.section-editor__head .actions button { background: transparent; border: 0; color: inherit; cursor: pointer; padding: 0; margin-left: 8px; }
|
||
.section-editor__field {
|
||
display: grid; grid-template-columns: 120px 1fr auto;
|
||
gap: 4px; margin-bottom: 4px; font-size: 11px;
|
||
}
|
||
.section-editor__field input {
|
||
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||
padding: 3px 6px; border-radius: 3px; font: inherit; font-size: 11px;
|
||
}
|
||
.section-editor__field .delete-field {
|
||
background: transparent; border: 0; color: #f85149; cursor: pointer;
|
||
font-size: 14px; padding: 0 4px;
|
||
}
|
||
.section-editor__add {
|
||
display: flex; gap: 6px; margin-top: 6px;
|
||
}
|
||
.section-editor__add button {
|
||
background: transparent; border: 1px solid #30363d; color: #8b949e;
|
||
padding: 2px 10px; border-radius: 3px; cursor: pointer; font-size: 10px;
|
||
font-family: inherit;
|
||
}
|
||
.section-editor__add button:hover { color: #c9d1d9; border-color: #484f58; }
|
||
.disclosure__body .add-section {
|
||
margin-top: 12px; background: transparent;
|
||
border: 1px dashed #30363d; color: #8b949e;
|
||
padding: 6px 10px; border-radius: 4px; cursor: pointer;
|
||
width: 100%; font-size: 11px; font-family: inherit;
|
||
}
|
||
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
|
||
```
|
||
|
||
## Slice 3 — Vault-settings SW plumbing
|
||
|
||
### Messages
|
||
|
||
`shared/messages.ts` — add to `PopupMessage`:
|
||
```ts
|
||
| { type: 'get_vault_settings' }
|
||
| { type: 'update_vault_settings'; settings: VaultSettings }
|
||
```
|
||
|
||
Add both to `POPUP_ONLY_TYPES`. NOT in `SETUP_ALLOWED`.
|
||
|
||
Add:
|
||
```ts
|
||
export interface VaultSettingsResponse extends Extract<Response, { ok: true }> {
|
||
data: { settings: VaultSettings };
|
||
}
|
||
```
|
||
|
||
### Handlers (`router/popup-only.ts`)
|
||
|
||
```ts
|
||
case 'get_vault_settings': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
const settings = await vault.fetchAndDecryptSettings(state.gitHost, handle);
|
||
return { ok: true, data: { settings } };
|
||
}
|
||
|
||
case 'update_vault_settings': {
|
||
const handle = session.getCurrent();
|
||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||
await vault.encryptAndWriteSettings(
|
||
state.gitHost, handle, msg.settings,
|
||
'settings: update vault-level config',
|
||
);
|
||
return { ok: true };
|
||
}
|
||
```
|
||
|
||
### Router tests
|
||
|
||
`router/__tests__/router.test.ts` (+4 cases):
|
||
- `get_vault_settings` accepted from popup (mock `fetchAndDecryptSettings` → returns a `VaultSettings`); response shape matches `VaultSettingsResponse`.
|
||
- `get_vault_settings` rejected from content → `unauthorized_sender`.
|
||
- `update_vault_settings` accepted from popup; calls `encryptAndWriteSettings`.
|
||
- `update_vault_settings` rejected from setup tab (not in SETUP_ALLOWED).
|
||
|
||
### Popup init
|
||
|
||
`popup.ts#init`, after a successful unlock-is-active branch:
|
||
```ts
|
||
const vsResp = await sendMessage({ type: 'get_vault_settings' });
|
||
if (vsResp.ok) {
|
||
const vs = (vsResp.data as { settings: VaultSettings }).settings;
|
||
currentState.vaultSettings = vs;
|
||
currentState.generatorDefaults = vs.generator_defaults as GeneratorRequest;
|
||
}
|
||
```
|
||
|
||
Fetched once at popup open; refreshed after any `update_vault_settings` success. The "fetch on open" cost is one extra round-trip over α — acceptable given vault-settings drives multiple screens.
|
||
|
||
### `generate_passphrase` message (add if missing)
|
||
|
||
The α plan lists `generate_password` as a popup-only message. The generator popover (Slice 4) also needs `generate_passphrase` for BIP39 preview. Check `shared/messages.ts`; if absent, add:
|
||
|
||
```ts
|
||
| { type: 'generate_passphrase'; request: GeneratorRequest }
|
||
```
|
||
|
||
Add to `POPUP_ONLY_TYPES`. The SW handler mirrors `generate_password` but calls the `generate_passphrase` WASM function. One new case in `router/popup-only.ts`.
|
||
|
||
## Slice 4 — Generator inline popover
|
||
|
||
### `popup/components/generator-popover.ts`
|
||
|
||
```ts
|
||
export function openGeneratorPopover(opts: {
|
||
anchor: HTMLElement;
|
||
initial: GeneratorRequest;
|
||
onPicked: (value: string) => void;
|
||
}): void;
|
||
|
||
export function closeGeneratorPopover(): void;
|
||
```
|
||
|
||
Module-scope state:
|
||
|
||
```ts
|
||
let activePopover: {
|
||
host: HTMLElement;
|
||
onDismiss: () => void;
|
||
} | null = null;
|
||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
```
|
||
|
||
### Layout (Random kind)
|
||
|
||
```
|
||
┌─ generate ────────────────── ✕ ┐
|
||
│ │
|
||
│ kind: [● Random] [○ BIP39] │
|
||
│ │
|
||
│ length: [════●═══════] 20 │
|
||
│ │
|
||
│ ☑ lowercase ☑ digits │
|
||
│ ☑ uppercase ☑ symbols │
|
||
│ │
|
||
│ symbols: [● safe] [○ extended] │
|
||
│ │
|
||
│ ─ preview ──────────────────── │
|
||
│ Kj7%pW@2xNq!8rMvT [↻] │
|
||
│ │
|
||
│ [reset to defaults] │
|
||
│ [save as default] │
|
||
│ [cancel] [use this value] │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
### Layout (BIP39 kind)
|
||
|
||
```
|
||
┌─ generate ────────────────── ✕ ┐
|
||
│ kind: [○ Random] [● BIP39] │
|
||
│ │
|
||
│ words: [═══●════════] 5 │
|
||
│ │
|
||
│ separator: [space] [-] [_] [.] [:]
|
||
│ │
|
||
│ capitalization: │
|
||
│ [● lower] [upper] [first] [title]
|
||
│ │
|
||
│ ─ preview ──────────────────── │
|
||
│ correct horse battery staple parapet
|
||
│ │
|
||
│ [reset to defaults] │
|
||
│ [save as default] │
|
||
│ [cancel] [use this value] │
|
||
└─────────────────────────────────┘
|
||
```
|
||
|
||
### Request construction
|
||
|
||
```ts
|
||
function buildRequest(kind: 'random' | 'bip39', knobs: UiKnobs): GeneratorRequest {
|
||
if (kind === 'random') {
|
||
return {
|
||
kind: 'random',
|
||
length: knobs.length,
|
||
classes: {
|
||
lower: knobs.lower, upper: knobs.upper,
|
||
digits: knobs.digits, symbols: knobs.symbols,
|
||
},
|
||
symbol_charset:
|
||
knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } :
|
||
knobs.symbolCharset === 'extended' ? { kind: 'extended' } :
|
||
{ kind: 'custom', value: knobs.customSymbols ?? '' },
|
||
};
|
||
}
|
||
return {
|
||
kind: 'bip39',
|
||
word_count: knobs.wordCount,
|
||
separator: knobs.separator,
|
||
capitalization: knobs.capitalization,
|
||
};
|
||
}
|
||
```
|
||
|
||
### Preview refresh
|
||
|
||
On any knob change, debounced 150ms:
|
||
```ts
|
||
async function refreshPreview(): Promise<void> {
|
||
const request = buildRequest(uiKind, uiKnobs);
|
||
const msg = uiKind === 'random'
|
||
? { type: 'generate_password' as const, request }
|
||
: { type: 'generate_passphrase' as const, request };
|
||
const resp = await sendMessage(msg);
|
||
if (resp.ok) {
|
||
const data = resp.data as { password?: string; passphrase?: string };
|
||
const previewEl = activePopover?.host.querySelector('.gen-preview__value');
|
||
if (previewEl) previewEl.textContent = data.password ?? data.passphrase ?? '';
|
||
}
|
||
}
|
||
```
|
||
|
||
Note: α added `generate_password` but `generate_passphrase` may need to be added (check α's `messages.ts`). If not present, add it alongside generate_password in slice 4's scope (router handler already accepts a `request_json` → WASM `generate_passphrase`).
|
||
|
||
### Validation
|
||
|
||
"use this value" button disabled when:
|
||
- Random kind and no char-class checked (`!lower && !upper && !digits && !symbols`).
|
||
- BIP39 kind never disabled (always valid — word count ≥ 3).
|
||
|
||
Visual cue: when disabled, button is dimmed + a `<p class="gen-validation">pick at least one character class</p>` renders below.
|
||
|
||
### Actions
|
||
|
||
- **use this value**: `onPicked(currentPreview); close();`. Host field's setter wraps this (e.g., `pw.value = value; pw.type = 'text';` for the Login form).
|
||
- **save as default**: fetch the full `vaultSettings` via `sendMessage({ type: 'get_vault_settings' })`; write `{ ...vaultSettings, generator_defaults: currentRequest }` via `update_vault_settings`. On success: update `state.vaultSettings` + `state.generatorDefaults`; flash "saved" on the button for 1.5s; do NOT close.
|
||
- **reset to defaults**: reset UI knobs to `state.generatorDefaults ?? DEFAULT_PASSWORD_REQUEST`; refresh preview.
|
||
- **cancel / Escape / outside-click**: close without callback.
|
||
|
||
### Teardown wiring
|
||
|
||
Every type module's existing `teardown()` gains:
|
||
```ts
|
||
closeGeneratorPopover();
|
||
```
|
||
So navigation or re-rendering always cleans up the popover.
|
||
|
||
### Tests
|
||
|
||
`__tests__/generator-popover.test.ts` (mocks `sendMessage`):
|
||
- Open with default initial → renders Random kind, shows `length=20`, all 4 classes checked, safe_only.
|
||
- BIP39 toggle → switches knobs to word-count / separator / capitalization; `sendMessage` called with `generate_passphrase`.
|
||
- Length slider change → debounced `generate_password` call with updated `length`.
|
||
- "use this value" → `onPicked` called with current preview string; popover closes.
|
||
- "save as default" → `update_vault_settings` called with the current request merged into vaultSettings.
|
||
- Uncheck all 4 classes in Random → "use this value" button disabled.
|
||
- Escape key → popover closes without invoking onPicked.
|
||
|
||
## Slice 5 — Settings view + revoke + default wiring
|
||
|
||
### Routing
|
||
|
||
`popup.ts`:
|
||
- Add `'settings-vault'` to the `View` union.
|
||
- Add the render-switch case pointing at `renderVaultSettings`.
|
||
- Toolbar ⚙ button on `item-list.ts` becomes a tiny picker (render inline, same pattern as the "+ New" picker):
|
||
|
||
```
|
||
⚙
|
||
├ device settings → navigate('settings')
|
||
└ vault settings → navigate('settings-vault')
|
||
```
|
||
|
||
### `popup/components/settings-vault.ts`
|
||
|
||
```ts
|
||
export function renderVaultSettings(app: HTMLElement): void;
|
||
```
|
||
|
||
Module-scope state:
|
||
- `pendingSettings: VaultSettings | null` — draft, initialized from `state.vaultSettings`, mutated by the screen.
|
||
- `teardown()` exported; removes any active key handler.
|
||
|
||
### Render body
|
||
|
||
```html
|
||
<div class="pad">
|
||
<div class="settings-header">
|
||
<button class="btn" id="back-btn">← back</button>
|
||
<h3>vault settings</h3>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">retention</div>
|
||
<div class="settings-row">
|
||
<span class="settings-row__label">trash</span>
|
||
<select id="trash-retention">...</select>
|
||
</div>
|
||
<div class="settings-row">
|
||
<span class="settings-row__label">field history</span>
|
||
<select id="history-retention">...</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">generator</div>
|
||
<p class="gen-preview-line">{humanSummary(pending.generator_defaults)}</p>
|
||
<button class="btn" id="configure-gen">configure ▾</button>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section__title">autofill origins</div>
|
||
{if empty: <p class="muted">No origins acknowledged yet.</p>}
|
||
{else: sorted ack rows with revoke buttons}
|
||
</div>
|
||
|
||
<div class="settings-footer">
|
||
<button class="btn" id="discard-btn">discard</button>
|
||
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### Retention dropdown semantics
|
||
|
||
`retentionSelectOptions(kind: 'trash' | 'history')`:
|
||
- Trash: `Forever`, `7 days`, `30 days`, `60 days`, `90 days`, `180 days`, `365 days`, `custom…`.
|
||
- History: `Forever`, `Last 3`, `Last 5`, `Last 10`, `30 days`, `90 days`, `365 days`, `custom…`.
|
||
|
||
`retentionToSelectValue(r)` maps a `TrashRetention` / `HistoryRetention` union to one of those option labels (falling back to `custom…` if it's an N that doesn't match a preset).
|
||
|
||
`selectValueToRetention(kind, label)` goes the other way. For `custom…`, `prompt()` the user for a number + unit.
|
||
|
||
### Generator-default preview
|
||
|
||
`humanSummary(req: GeneratorRequest): string`:
|
||
- Random: `"Random, {length} chars, {classes joined with +}, {symbolCharset label}"`.
|
||
- BIP39: `"BIP39, {word_count} words, {separator label}-separated, {capitalization}"`.
|
||
|
||
Clicking "configure ▾" opens the generator popover (`openGeneratorPopover`) with `onPicked: () => {}` (no-op — the user's intent here is "save as default", not "insert into a field"). On popover close (after save-as-default or cancel), refresh `state.vaultSettings` via a `get_vault_settings` round-trip and re-render the settings screen. (The popover's "save as default" already calls `update_vault_settings` itself.)
|
||
|
||
### Origin-ack list
|
||
|
||
Sorted by `Object.entries(acks).sort(([, a], [, b]) => b - a)` (most recent first).
|
||
|
||
Each row:
|
||
```html
|
||
<div class="ack-row">
|
||
<span class="ack-row__host">github.com</span>
|
||
<span class="ack-row__meta">acked 3d ago</span>
|
||
<button class="ack-row__revoke" data-host="github.com">revoke</button>
|
||
</div>
|
||
```
|
||
|
||
Revoke handler: `delete pending.autofill_origin_acks[host]; rerender(); markDirty();`.
|
||
|
||
### Save / discard
|
||
|
||
`markDirty()` enables the save button. `save` sends `update_vault_settings` with `pending`; on success, updates `state.vaultSettings` + `state.generatorDefaults` and navigates back to the list. `discard` just navigates back.
|
||
|
||
### Tests
|
||
|
||
`__tests__/settings-vault.test.ts`:
|
||
- Render with seeded `state.vaultSettings` — correct retention labels shown.
|
||
- Change trash-retention select → `pending` updated; save button enabled.
|
||
- Click revoke on an ack → `pending.autofill_origin_acks` loses that key; save button enabled.
|
||
- Save → `update_vault_settings` called with `pending`; navigates back.
|
||
- Discard → no message sent; navigates back.
|
||
|
||
### CSS
|
||
|
||
Additions in `popup/styles.css`:
|
||
|
||
```css
|
||
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||
.settings-header h3 { margin: 0; font-size: 14px; }
|
||
.settings-section {
|
||
margin-top: 14px; padding-top: 10px;
|
||
border-top: 1px solid #21262d;
|
||
}
|
||
.settings-section__title {
|
||
color: #8b949e; font-size: 10px;
|
||
text-transform: uppercase; letter-spacing: 0.08em;
|
||
margin-bottom: 6px;
|
||
}
|
||
.settings-row {
|
||
display: grid; grid-template-columns: 110px 1fr;
|
||
gap: 6px 10px; align-items: center;
|
||
margin: 4px 0; font-size: 12px;
|
||
}
|
||
.settings-row__label { color: #8b949e; }
|
||
.settings-row select {
|
||
background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||
padding: 3px 8px; border-radius: 3px; font: inherit; font-size: 11px;
|
||
}
|
||
.gen-preview-line {
|
||
margin: 0 0 6px; font-size: 11px; color: #c9d1d9;
|
||
font-family: "SF Mono", "JetBrains Mono", monospace;
|
||
}
|
||
.ack-row {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
padding: 4px 0; font-size: 11px;
|
||
border-bottom: 1px solid #161b22;
|
||
}
|
||
.ack-row__host { color: #c9d1d9; font-family: monospace; }
|
||
.ack-row__meta { color: #6e7681; font-size: 10px; }
|
||
.ack-row__revoke {
|
||
background: transparent; border: 0; color: #f85149;
|
||
cursor: pointer; font-size: 10px;
|
||
}
|
||
.settings-footer {
|
||
display: flex; justify-content: flex-end; gap: 6px;
|
||
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
|
||
}
|
||
.btn-primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
```
|
||
|
||
## Testing
|
||
|
||
### Rust
|
||
No Rust changes. `cargo test --workspace` stays green (155 tests from β₁).
|
||
|
||
### Vitest
|
||
Existing 84 tests stay green. New tests:
|
||
- `types/__tests__/sections-render.test.ts` — ~5 tests.
|
||
- `types/__tests__/sections-edit.test.ts` (or per-type variants as appropriate) — ~5 tests.
|
||
- `__tests__/generator-popover.test.ts` — ~7 tests.
|
||
- `router/__tests__/router.test.ts` (extensions) — ~4 tests.
|
||
- `__tests__/settings-vault.test.ts` — ~5 tests.
|
||
|
||
Target post-β₂: ~110 tests.
|
||
|
||
### Manual matrix
|
||
|
||
1. Add a Login item; in the form's disclosure, add a section named "recovery codes" with two password fields; save; open detail → sections appear below typed rows; reveal works on each concealed row; copy works on text rows.
|
||
2. Edit the same item; remove one field; add a text field; save; detail reflects all three changes.
|
||
3. Click ⚙ → vault settings; change trash retention to `7 days`; save; reload → still `7 days`.
|
||
4. In vault settings, click "configure ▾" on the generator preview; change kind to BIP39; save as default; close popover; preview shows BIP39 summary. Reload → still BIP39.
|
||
5. Back on Login form, click "gen" → popover opens with BIP39 defaults (inherited from settings).
|
||
6. "use this value" on the popover fills the password field with a BIP39 phrase.
|
||
7. Revoke an origin ack; save; attempt autofill on that site → requires-ack flow re-triggers (per α's content-callable handler).
|
||
8. Kind toggle mid-popover switches Random ↔ BIP39; preview refreshes; request shape correct.
|
||
|
||
### Acceptance
|
||
|
||
- `cargo test --workspace` green.
|
||
- `bun run test` green (~110 tests).
|
||
- `bun run build:all` green for Chrome + Firefox.
|
||
- `git grep -n '@ts-nocheck' extension/src/` → 0.
|
||
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ | grep -v document` → 0.
|
||
- Manual matrix 8 steps pass on both browsers.
|
||
|
||
## Open questions deferred to plan
|
||
|
||
- `generate_passphrase` message type: α shipped `generate_password`; if the message union lacks `generate_passphrase`, add it in Slice 4 alongside the vault-settings messages. The SW router just needs an additional case mirroring `generate_password`.
|
||
- Custom-field label blanks: what happens when a field has an empty `label`? Options: (a) reject at save time; (b) allow and render as "(unnamed)". Plan ships (b) — no UX friction; render the value row with the row's label span empty.
|
||
- Retention `custom…`: is the `prompt()` acceptable UX, or should it be an inline number + unit input? Plan ships `prompt()` (matches existing rename-section UX); can polish in a later pass.
|
||
- Deep-equal check for save-button enable: `JSON.stringify(a) === JSON.stringify(b)` is cheap and sufficient for the `VaultSettings` shape (no Map/Set/Date keys). Avoids a util dependency.
|