Files
relicario/docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
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>
2026-05-01 17:29:10 -04:00

402 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Relicario — Extension Plan 1C-β₁ (Typed-Item Forms) Design
Second of three sub-plans porting the extension to the typed-item core. 1C-α (foundation) shipped Login-parity; 1C-β₁ adds the **other 5 typed-item forms** so the extension can daily-drive every typed item the Rust core knows about (except Document, deferred to γ for attachment dependencies). Custom-fields editor, vault-settings view, and advanced generator UI move to **β₂**.
Reference: 1C-α design `docs/superpowers/specs/2026-04-20-relicario-extension-1c-alpha-design.md` (commits `a1d733d`, `ad6d8af`); 1C-α implementation merged 2026-04-22 (`2b83105`, tag `plan-1c-alpha-complete`).
## Plan 1C decomposition (post-α refinement)
| Sub-plan | Status | Scope |
|---|---|---|
| 1C-α | shipped 2026-04-22 | WASM rebuild, shared TS types, SessionHandle SW, split router with sender checks, full security architecture, Login-parity popup, zxcvbn setup gate |
| **1C-β₁ (this spec)** | proposed | 5 typed-item forms (SecureNote / Identity / Card / Key / Totp) + Rust Steam encoding fix |
| 1C-β₂ | proposed | Custom fields editor, full vault-settings view, advanced generator-request UI |
| 1C-γ | proposed | Attachments (with `putBlob` Git-Data-API fallback), Document type, trash view, field-history view, device management |
## Design Decisions (from brainstorming)
| Question | Decision | Why |
|---|---|---|
| Does β stay one plan or split? | **β₁ + β₂** | Settings view + custom-fields editor are heavy independently; splitting unlocks daily-driver typed items as soon as β₁ ships |
| Document type in β₁? | **Defer to γ** | `DocumentCore.primary_attachment` is required; without attachment upload there's nothing to attach |
| Form visual style? | **Type-flavored, muted** | "Signature block + uniform rows" pattern: each type gets one accent panel + plain rows for the rest. Lower contrast than vivid v1 mockup, sits with the dark-terminal aesthetic |
| Totp variants in β₁? | **TOTP + Steam** (Hotp deferred) | Steam Guard is widely used; Hotp is rare and needs counter-persistence UX |
| Steam encoding in Rust core? | **Yes — fix as Slice 1** | Existing `compute_totp_code` returns decimal output for `kind: 'steam'`, which doesn't match Steam Guard. ~30 line patch + test vectors |
| Sequencing? | **5 slices: Rust Steam → shared helpers + Login refactor → SecureNote+Identity → Card+Key → Totp** | Helper extraction pays off across 5 forms; pairing trivial types together; Totp last because it depends on Steam fix |
| Custom fields in β₁? | **No — β₂** | Custom fields are the single hardest UI in β; deserves its own focused cycle |
## Scope
### In
- 5 typed-item forms wired end-to-end (view + add + edit + delete): SecureNote, Identity, Card, Key, Totp.
- Form style: muted "signature block + uniform rows" with thin left-border accent per type.
- **Steam Guard** support on Totp items: `kind: 'totp'` and `kind: 'steam'` selectable in the form; UI toggle (no dropdown).
- **Rust core fix**: `compute_totp_code` learns the Steam alphabet (`23456789BCDFGHJKMNPQRTVWXY`, 5-char output).
- Concealed-with-reveal+copy pattern applied to: `Card.number`, `Card.cvv`, `Card.pin`, `Key.key_material`, `Totp.secret` (rendered as base32). Re-uses Login's existing convention via a new shared helper.
- Shared helper module `extension/src/popup/components/fields.ts` for row / concealed-row / signature-block primitives. **Login refactored onto it** as the reference implementation (net code reduction even before adding 5 new types).
- `item-detail.ts` and `item-form.ts` collapse to thin dispatchers calling `types/<x>.renderDetail()` / `renderForm()`.
- "New…" picker on the toolbar's `+ New` button, listing all 7 types (Document greyed/disabled with "coming in γ" tooltip).
- Per-type Vitest unit tests for the form→Item transform.
### Out (→ β₂ / γ)
- Custom fields editor (sections + per-field add/rename/remove/reorder). β₂.
- Vault-settings view (retention, generator defaults, attachment caps). β₂.
- Advanced generator-request UI (BIP39 vs Random, charset toggles, length slider). β₂.
- Hotp counter UI. β₂ or later.
- Per-type form custom defaults (e.g. exposing `Totp.digits` / `Totp.period_seconds`). β₂ via the custom-fields editor.
- Document type. γ.
- Attachment upload, trash view, field-history view, device-management UI. γ.
## File map
### New
```
crates/relicario-core/src/item_types/totp.rs # Steam alphabet output (modified)
extension/src/popup/components/fields.ts # row / concealed-row / signature-block helpers
extension/src/popup/components/types/login.ts # extracted from existing item-detail/form Login branches
extension/src/popup/components/types/secure-note.ts
extension/src/popup/components/types/identity.ts
extension/src/popup/components/types/card.ts
extension/src/popup/components/types/key.ts
extension/src/popup/components/types/totp.ts
extension/src/popup/components/__tests__/fields.test.ts
extension/src/popup/components/types/__tests__/save-shape.test.ts
```
### Modified
```
extension/src/popup/components/item-detail.ts # dispatch on item.type → types/<x>.renderDetail
extension/src/popup/components/item-form.ts # dispatch on item.type → types/<x>.renderForm
extension/src/popup/components/item-list.ts # "+ New" button opens type picker
extension/src/popup/styles.css # signature-block + field-row classes
crates/relicario-core/src/item_types/totp.rs # see above
```
### Deleted
None.
## Slice 1 — Rust Steam encoding
**File**: `crates/relicario-core/src/item_types/totp.rs`
Patch shape:
```rust
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
pub fn compute_totp_code(config: &TotpConfig, now_unix_seconds: u64) -> Result<String> {
let counter = match config.kind {
TotpKind::Totp => now_unix_seconds / config.period_seconds as u64,
TotpKind::Hotp { counter } => counter,
TotpKind::Steam => now_unix_seconds / config.period_seconds as u64,
};
// ... existing HMAC + dynamic-truncation logic produces `truncated: u32` ...
if matches!(config.kind, TotpKind::Steam) {
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(STEAM_ALPHABET[(t % 26) as usize] as char);
t /= 26;
}
return Ok(out);
}
let modulus = 10u32.pow(config.digits as u32);
Ok(format!("{:0width$}", truncated % modulus, width = config.digits as usize))
}
```
`STEAM_ALPHABET` deliberately excludes `0`, `O`, `1`, `I`, `L`, `S`, `5`, `A`, `Z`. Same alphabet used by Steam Mobile Authenticator and WinAuth.
### Tests (in the same file)
- `steam_known_vector`: pin a `(secret, counter)` to its known Steam output. If a citeable third-party vector is available, prefer it; otherwise pin the value our impl computes today (regression test against accidental future change).
- `steam_alphabet_no_ambiguous_chars`: `assert!(!STEAM_ALPHABET.contains(&b'0' / &b'O' / &b'1' / &b'I' / &b'L' / &b'S' / &b'5' / &b'A' / &b'Z'))`.
- `steam_output_is_5_chars`: regardless of `config.digits`, Steam output is exactly 5 characters.
- `totp_kind_decimal_unaffected`: existing RFC 6238 vectors for `kind: 'totp'` still pass byte-for-byte.
### WASM impact
`totp_compute` in `crates/relicario-wasm/src/lib.rs` doesn't change — it forwards `kind` through serde. The TS `TotpKind` shape in `extension/src/shared/types.ts` is already correct. Only the Rust-side compute body changes.
## Slice 2 — Shared field helpers + Login refactor
### `extension/src/popup/components/fields.ts`
Pure functions returning HTML strings + a small mount-time event-binding helper. No DOM ownership, no state.
```ts
import { escapeHtml } from '../popup';
export interface RowOpts {
label: string;
value: string;
copyable?: boolean;
href?: string; // wraps value in <a target="_blank" rel="noopener">
monospace?: boolean;
multiline?: boolean; // renders as <pre> instead of inline
}
export function renderRow(opts: RowOpts): string;
export interface ConcealedRowOpts {
id: string; // unique within the rendered detail view
label: string;
value: string; // plaintext; rendered hidden until user reveals
monospace?: boolean;
multiline?: boolean; // <pre> when revealed; "•••• (N chars)" when hidden
}
export function renderConcealedRow(opts: ConcealedRowOpts): string;
export interface SignatureBlockOpts {
accent?: 'blue' | 'green' | 'amber' | 'red'; // default 'blue'
children: string; // HTML, caller's responsibility to escape
}
export function renderSignatureBlock(opts: SignatureBlockOpts): string;
/// Wire reveal-toggle + copy handlers for all rows rendered above.
/// Call once after the parent's innerHTML lands.
export function wireFieldHandlers(scope: HTMLElement): void;
```
`wireFieldHandlers` looks for `data-field-action="reveal"` and `data-field-action="copy"` attributes inside `scope` and binds click handlers. Reveal toggles a `data-revealed` attribute on the row's value `<span>`/`<pre>`; copy uses `navigator.clipboard.writeText` and flashes a 1.5s "copied" badge.
### CSS additions in `extension/src/popup/styles.css`
```css
.field-row {
display: grid;
grid-template-columns: 90px 1fr auto;
gap: 8px 10px;
align-items: baseline;
padding: 4px 0;
font-size: 12px;
}
.field-row__label { color: #8b949e; }
.field-row__value { color: #c9d1d9; }
.field-row__value.monospace { font-family: "SF Mono", "JetBrains Mono", monospace; }
.field-row__value pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
.field-row__actions { display: flex; gap: 6px; font-size: 11px; color: #8b949e; }
.field-row__actions button {
background: transparent; border: 0; color: inherit;
cursor: pointer; padding: 0; font: inherit;
}
.field-row__actions button:hover { color: #c9d1d9; }
.sig-block {
background: #161b22;
border: 1px solid #30363d;
border-left: 3px solid #1f6feb;
border-radius: 5px;
padding: 14px;
margin-bottom: 10px;
}
.sig-block--blue { border-left-color: #1f6feb; }
.sig-block--green { border-left-color: #3fb950; }
.sig-block--amber { border-left-color: #d29922; }
.sig-block--red { border-left-color: #f85149; }
```
### Login refactor (same slice)
Extract `popup/components/types/login.ts` exporting `renderDetail(app, item)` / `renderForm(app, mode, existing)` / private `saveLogin(...)`. The bodies are the existing Login-branch code from `item-detail.ts` / `item-form.ts`, ported to use `renderRow` / `renderConcealedRow` / `renderSignatureBlock` instead of inline string concatenation.
Net-line check: this slice should reduce total LOC slightly (helper consolidation) before adding any new types.
### Helper unit tests (`fields.test.ts`)
- `renderRow` produces expected HTML for plain / copyable / linked / monospace / multiline cases.
- `renderConcealedRow` produces the hidden initial state, includes the unique id in `data-field-id`, has show + copy buttons, hides multiline value as `"•••• (N chars)"`.
- `renderSignatureBlock` wraps children correctly with each accent class.
- `wireFieldHandlers`: with a happy-dom `<div>` containing rendered rows, clicking the show button toggles `data-revealed`; clicking copy calls `navigator.clipboard.writeText` (mock).
## Slices 35 — Per-type designs
### SecureNote (Slice 3a)
**Data**: `SecureNoteCore { body: Zeroizing<String> }`.
**Detail view**: title at top, then a single signature block (accent `green`) containing the body rendered as a concealed `<pre>` block (multiline concealed row). Copy button copies the whole body verbatim. No other rows.
**Form view**: a single `<textarea>` (10-row default) for the body. Title at the top (always required on the Item envelope, not on the body field). No signature-block visual on the form — the textarea is the content.
### Identity (Slice 3b)
**Data**: `IdentityCore { full_name?, address? (multiline), phone?, email?, date_of_birth? }`.
**Detail view**: title at top; signature block (accent `amber`) with a monogram "avatar" (initials extracted from `full_name`, or `?`) + the name in larger type. Below the block, plain rows in this order: phone, email, address (multiline), date_of_birth (formatted as the user's locale via `toLocaleDateString`). Email and phone are copyable.
**Form view**: plain rows:
- `full_name`: `<input type="text">`
- `address`: `<textarea>` (3 rows)
- `phone`: `<input type="tel">`
- `email`: `<input type="email">` (browser-native validation surfaces on submit)
- `date_of_birth`: `<input type="date">` — wire format matches Rust `NaiveDate`'s `"YYYY-MM-DD"` serialization
Empty strings → `undefined` per the established convention.
### Card (Slice 4a)
**Data**: `CardCore { number?, holder?, expiry?: MonthYear, cvv?, pin?, kind: CardKind }`. `MonthYear = { month, year }`. `CardKind = 'credit' | 'debit' | 'gift' | 'loyalty' | 'other'`.
**Detail view**: title at top; signature block (accent `blue`) matching the v2 mockup:
- Top label band: `"<BRAND> · <KIND>"` uppercased (brand derived from card BIN; see below)
- Masked card number with reveal toggle, monospace, letter-spaced
- Footer: HOLDER (left) and EXPIRES (right)
Below the signature block: concealed rows for `cvv` and `pin`.
Brand derivation (display-only, not stored):
```ts
function brandFromNumber(num: string): string {
if (/^3[47]/.test(num)) return 'AMEX';
if (/^4/.test(num)) return 'VISA';
if (/^5[1-5]/.test(num)) return 'MASTERCARD';
if (/^6/.test(num)) return 'DISCOVER';
return '';
}
```
**Form view**: plain rows:
- `number`: `<input type="text" inputmode="numeric">`, no formatting on the form (paste-friendly)
- `holder`: `<input type="text">`
- `expiry`: two side-by-side `<select>`s — month (`01``12`) + year (current ± 25). Saves as `{ month: number, year: number }`. Empty selection → `undefined` for the whole `expiry`.
- `cvv`: `<input type="password" inputmode="numeric" maxlength="4">`
- `pin`: `<input type="password" inputmode="numeric" maxlength="8">`
- `kind`: `<select>` with the 5 enum values, default `credit`
### Key (Slice 4b)
**Data**: `KeyCore { key_material: Zeroizing<String>, label?, public_key?, algorithm? }`. `key_material` is required.
**Detail view**: title at top; signature block (accent `green`) showing the `key_material` as a concealed monospace `<pre>` block. Below: plain rows for `label`, `algorithm` (free-form text), `public_key` (multiline monospace, **not concealed** — public keys are public).
**Form view**: plain rows:
- `key_material`: `<textarea>` (8 rows, monospace) with a sibling `[show]` toggle button (since `<textarea>` doesn't honor `type="password"`). Default state: a CSS rule sets `-webkit-text-security: disc` to mask characters; clicking the toggle removes the rule.
- `label`: `<input type="text">`
- `public_key`: `<textarea>` (4 rows, monospace, no masking)
- `algorithm`: `<input type="text">` placeholder `"ed25519"`
### Totp (Slice 5)
**Data**: `TotpCore { config: TotpConfig, issuer?, label? }`. `TotpConfig = { secret: number[], algorithm: 'sha1'|'sha256'|'sha512', digits: number, period_seconds: number, kind: TotpKind }`. β₁ supports `kind: 'totp'` and `kind: 'steam'`.
**Detail view**: title at top (uses `issuer / label` to construct a default if title is empty: `"<issuer>: <label>"`). Signature block (accent `blue`) shows:
- Large monospace rotating code (centered, 28pt)
- Thin SVG countdown ring at the right side, sized 32×32
Below the block: plain rows for `issuer`, `label`, and a concealed row for `secret` (rendered as base32 via `shared/base32.ts` `base32Encode`).
The ring re-tick interval is 1000ms; on each tick it calls `chrome.runtime.sendMessage({ type: 'get_totp', id })` (the existing α handler in `router/popup-only.ts` — no new message type). The countdown value is `(expires_at - now)` per the existing `TotpResponse`.
**Form view**: a kind toggle at the top, then plain rows:
```
┌─ kind ──────────────────────────┐
│ [● TOTP] [○ Steam Guard] │
└─────────────────────────────────┘
secret (base32): [_______________]
issuer: [_______________]
label: [_______________]
```
Toggle is a two-button group; click switches `state.kind` and re-renders the small subtitle below ("Standard time-based codes" vs "Steam Mobile Authenticator (5-char alphanumeric)"). For both kinds, `digits` / `period_seconds` / `algorithm` are written with their defaults (`6`/`30`/`sha1` for TOTP; `5`/`30`/`sha1` for Steam — Steam's compute uses the alphabet, ignoring the digits field). Power users who need non-default values use the CLI; β₂ may add a `[more options ▾]` disclosure on the Totp form if this turns out to bite real users.
`secret` parsed via `base32Decode` from `shared/base32.ts` (already exists). Empty string is rejected with a friendly error from the popup's `humanizeError` path.
### Dispatcher updates
`item-detail.ts` after β₁:
```ts
import * as login from './types/login';
import * as secureNote from './types/secure-note';
import * as identity from './types/identity';
import * as card from './types/card';
import * as key from './types/key';
import * as totp from './types/totp';
export async function renderItemDetail(app: HTMLElement): Promise<void> {
const item = getState().selectedItem;
if (!item) { navigate('list'); return; }
switch (item.type) {
case 'login': return login.renderDetail(app, item);
case 'secure_note': return secureNote.renderDetail(app, item);
case 'identity': return identity.renderDetail(app, item);
case 'card': return card.renderDetail(app, item);
case 'key': return key.renderDetail(app, item);
case 'totp': return totp.renderDetail(app, item);
case 'document': return renderComingSoonPlaceholder(app, item.type);
}
}
```
`item-form.ts` follows the same shape with `renderForm(app, mode, existing)`.
### "New…" picker
`item-list.ts`'s `+ New` button opens a small picker (popover anchored to the button):
```
new item
🔑 login
📝 secure note
🪪 identity
💳 card
🗝 key
⏱ totp
📄 document ← greyed; tooltip "coming in γ — needs attachment upload"
```
Selecting a type stores `state.newType` (transient — added to PopupState with `'login' | 'secure_note' | …`) and navigates to `'add'`. The form dispatcher reads `state.newType` for add-mode and `state.selectedItem.type` for edit-mode.
The popover lives in the popup's own DOM (no closed Shadow DOM needed — the popup is its own origin and not subject to page-injection threats). Standard `<div>` with `position: absolute` anchored to the button.
## Testing
### Rust
`cargo test --workspace` stays green. New tests in `crates/relicario-core/src/item_types/totp.rs` listed in §Slice 1.
### Vitest
Existing 55 tests stay green. New:
- `extension/src/popup/components/__tests__/fields.test.ts` (helper unit tests, ~12 cases).
- `extension/src/popup/components/types/__tests__/save-shape.test.ts` (per-type form→Item transform, ~5 cases × ~3 sub-assertions = ~15 cases).
The save-shape tests use happy-dom to render each form's HTML, populate inputs, fire the save handler, and intercept the `add_item` message via a `vi.fn()` shim of `chrome.runtime.sendMessage`. Asserts cover:
- SecureNote: `core.body === '<input value>'`, `core.type === 'secure_note'`.
- Identity: each present field in JS shape matches the wire format; absent fields are `undefined` (not empty string).
- Card: `expiry === { month: 8, year: 2029 }`; concealed fields (`number`/`cvv`/`pin`) round-trip through the form values; `kind` matches the select.
- Key: `key_material` always present; `algorithm` free-form.
- Totp: `config.secret === Array.from(base32Decode('JBSWY3DPEHPK3PXP'))`; `config.kind === 'totp'` or `'steam'` depending on toggle; for Steam, `config.digits === 5`.
### Manual matrix
Re-run the α matrix's 11 steps (§5.4 of α spec) plus, per type:
1. Add a new item of the type → it appears in the list with the right icon.
2. Open the item → detail view renders correctly (signature block + rows; no console errors).
3. For types with concealed fields: click reveal → value appears; click copy → clipboard contains the value.
4. Edit → save → list updates with new modified time; detail reflects changes.
5. Trash → moves out of the live list; CLI `relicario list --trashed` shows it.
6. For Totp: code rotates every 30s; Steam Guard kind produces 5-char alphanumeric; TOTP kind produces 6-digit decimal; switching kinds in the edit form re-renders the detail view's compute output correctly after save.
### Acceptance
- `cargo test --workspace` green.
- `bun run test` green.
- `bun run build:all` green for both Chrome and Firefox.
- `git grep -n 'coming-soon\|Coming in' extension/src/popup/components/` returns hits ONLY for `'document'`.
- All 5 type matrices pass on Chrome and Firefox.
- No new lint regressions; `git grep -n '@ts-nocheck' extension/src/` returns zero.
## Open questions deferred to the plan
- Exact CSS sizing of the Totp signature block's countdown ring (32px or 40px). Picked at implementation time.
- Whether the Card brand-from-BIN is comprehensive enough (currently 4 brands). Likely fine for α/β₁ — extending the table is a one-line change.
- For Steam toggle UX: a two-button group or a dropdown. Brainstorming locked in two-button; implementation may push back if it's awkward at popup width.
- Whether to expose `Totp.algorithm` / `digits` / `period_seconds` to power users via a `[more options ▾]` disclosure on the form. β₁ defaults them; β₂ revisits if the CLI workaround friction is real.