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>
22 KiB
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'andkind: 'steam'selectable in the form; UI toggle (no dropdown). - Rust core fix:
compute_totp_codelearns 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.tsfor 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.tsanditem-form.tscollapse to thin dispatchers callingtypes/<x>.renderDetail()/renderForm().- "New…" picker on the toolbar's
+ Newbutton, 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 ofconfig.digits, Steam output is exactly 5 characters.totp_kind_decimal_unaffected: existing RFC 6238 vectors forkind: '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)
renderRowproduces expected HTML for plain / copyable / linked / monospace / multiline cases.renderConcealedRowproduces the hidden initial state, includes the unique id indata-field-id, has show + copy buttons, hides multiline value as"•••• (N chars)".renderSignatureBlockwraps children correctly with each accent class.wireFieldHandlers: with a happy-dom<div>containing rendered rows, clicking the show button togglesdata-revealed; clicking copy callsnavigator.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 RustNaiveDate'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 (01–12) + year (current ± 25). Saves as{ month: number, year: number }. Empty selection →undefinedfor the wholeexpiry.cvv:<input type="password" inputmode="numeric" maxlength="4">pin:<input type="password" inputmode="numeric" maxlength="8">kind:<select>with the 5 enum values, defaultcredit
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 honortype="password"). Default state: a CSS rule sets-webkit-text-security: discto 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;kindmatches the select. - Key:
key_materialalways present;algorithmfree-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:
- Add a new item of the type → it appears in the list with the right icon.
- Open the item → detail view renders correctly (signature block + rows; no console errors).
- For types with concealed fields: click reveal → value appears; click copy → clipboard contains the value.
- Edit → save → list updates with new modified time; detail reflects changes.
- Trash → moves out of the live list; CLI
relicario list --trashedshows it. - 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 --workspacegreen.bun run testgreen.bun run build:allgreen 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_secondsto power users via a[more options ▾]disclosure on the form. β₁ defaults them; β₂ revisits if the CLI workaround friction is real.