Files
relicario/docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md
adlee-was-taken 1b51b7dbab docs: Plan 1C-β₁ (typed-item forms) design spec
Second sub-plan after 1C-α. Adds the 5 remaining typed-item forms
(SecureNote, Identity, Card, Key, Totp) so the extension can daily-
drive every typed item the Rust core supports — Document deferred
to γ for attachment dependencies.

Form style: muted "signature block + uniform rows" pattern
(per-type accent panel + plain rows for the rest). Login is
refactored onto a shared field-helper module as the reference
implementation.

Totp covers `kind: 'totp'` and `kind: 'steam'`. The latter requires
a Rust-core fix (Slice 1) — `compute_totp_code` currently produces
decimal output for Steam but Steam Guard uses a 5-char alphabet
(`23456789BCDFGHJKMNPQRTVWXY`). Plan ships the alphabet patch and
RFC-style test vectors.

Five-slice sequencing: Rust Steam → shared helpers + Login
refactor → SecureNote+Identity → Card+Key → Totp.

Custom fields editor, vault-settings view, advanced generator UI
all moved to β₂. Hotp counter UI deferred. Document type stays in γ.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 18:08:43 -04:00

22 KiB
Raw Blame History

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:

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.

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

.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):

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 (0112) + 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 β₁:

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.