# 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/.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/.renderDetail extension/src/popup/components/item-form.ts # dispatch on item.type → types/.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 { 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 monospace?: boolean; multiline?: boolean; // renders as
 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;    // 
 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 ``/`
`; 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 `
` containing rendered rows, clicking the show button toggles `data-revealed`; clicking copy calls `navigator.clipboard.writeText` (mock). ## Slices 3–5 — Per-type designs ### SecureNote (Slice 3a) **Data**: `SecureNoteCore { body: Zeroizing }`. **Detail view**: title at top, then a single signature block (accent `green`) containing the body rendered as a concealed `
` block (multiline concealed row). Copy button copies the whole body verbatim. No other rows.

**Form view**: a single `