# 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 3–5 — 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.
