docs: Plan 1C-β₂ (custom fields + settings + generator UI) design spec
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>
This commit is contained in:
@@ -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>` + `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.
|
||||
Reference in New Issue
Block a user