Files
relicario/docs/superpowers/plans/2026-04-22-relicario-extension-1c-beta1.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

2717 lines
109 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 1C-β₁ (Typed-Item Forms) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add the 5 remaining typed-item forms (SecureNote, Identity, Card, Key, Totp incl. Steam Guard) so the Relicario extension can daily-drive every typed item the Rust core supports except Document.
**Architecture:** 5-slice bottom-up sequencing. Slice 1 patches the Rust core's `compute_totp_code` to emit Steam's 5-char alphabet output. Slice 2 extracts a shared `popup/components/fields.ts` helper module (row / concealed-row / signature-block primitives) and refactors Login onto it as the reference implementation. Slices 3-5 land the 5 new types in pairs: SecureNote+Identity (no signature block), Card+Key (signature block, no live state), Totp (signature block + countdown + Steam toggle).
**Tech Stack:** Rust (`relicario-core`), TypeScript (extension popup), Vitest + happy-dom (existing test harness from α), Bun (package manager).
**Reference spec:** `docs/superpowers/specs/2026-04-22-relicario-extension-1c-beta1-design.md` (commit `1b51b7d`)
**Branch:** Create `feature/typed-items-1c-beta1` off `main` (1C-α merged at `2b83105`, tag `plan-1c-alpha-complete`).
---
## Pre-flight
- [ ] **P1: Verify main is clean and tests are green**
```bash
cd /home/alee/Sources/relicario
git status
git checkout main && git pull
cargo test --workspace 2>&1 | tail -3
```
Expected: working tree clean, on `main`, all Rust tests pass.
- [ ] **P2: Create the feature worktree**
```bash
cd /home/alee/Sources/relicario
git worktree add .worktrees/typed-items-1c-beta1 -b feature/typed-items-1c-beta1
cd .worktrees/typed-items-1c-beta1/extension
bun install
bun run test 2>&1 | tail -3
```
Expected: 55 Vitest tests pass (the α baseline).
---
## Slice 1 — Rust Steam encoding fix
Goal: `compute_totp_code` in `crates/relicario-core/src/item_types/totp.rs` learns to emit Steam Guard's 5-character alphabet output for `kind: 'steam'`. Standard TOTP/HOTP outputs unchanged.
### Task 1: Add Steam alphabet to `compute_totp_code`
**Files:**
- Modify: `crates/relicario-core/src/item_types/totp.rs`
- [ ] **Step 1: Read the current implementation**
Read `crates/relicario-core/src/item_types/totp.rs`. The file already has `compute_totp_code` taking a `&TotpConfig` and returning `Result<String>`. Locate the `format!("{:0width$}", ...)` final line — that's where the decimal-only output happens.
- [ ] **Step 2: Write failing tests**
Add the following test module **at the bottom** of `crates/relicario-core/src/item_types/totp.rs`, alongside the existing `#[cfg(test)] mod compute_tests` and `#[cfg(test)] mod tests`:
```rust
#[cfg(test)]
mod steam_tests {
use super::*;
/// Reference implementation of the Steam 5-character output, per the
/// Steam Mobile Authenticator (and WinAuth's Steam-Guard adapter).
/// Used by tests below to cross-check the production impl without
/// requiring a third-party vector. The algorithm is short enough to
/// be reproduced here in isolation.
fn steam_output_reference(truncated: u32) -> String {
const ALPHA: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
let mut t = truncated;
let mut out = String::with_capacity(5);
for _ in 0..5 {
out.push(ALPHA[(t % 26) as usize] as char);
t /= 26;
}
out
}
/// Compute the dynamic-truncated u32 the same way `compute_totp_code`
/// does internally — used to drive the reference impl.
fn truncated_for(secret: &[u8], counter: u64) -> u32 {
use hmac::{Hmac, Mac};
use sha1::Sha1;
let mut mac = Hmac::<Sha1>::new_from_slice(secret).unwrap();
mac.update(&counter.to_be_bytes());
let bytes = mac.finalize().into_bytes();
let offset = (bytes[bytes.len() - 1] & 0x0F) as usize;
((bytes[offset] as u32 & 0x7F) << 24)
| ((bytes[offset + 1] as u32) << 16)
| ((bytes[offset + 2] as u32) << 8)
| (bytes[offset + 3] as u32)
}
#[test]
fn steam_output_matches_reference_impl() {
let secret = b"12345678901234567890".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code_at_30 = compute_totp_code(&cfg, 30).unwrap();
let code_at_60 = compute_totp_code(&cfg, 60).unwrap();
let code_at_120 = compute_totp_code(&cfg, 120).unwrap();
assert_eq!(code_at_30, steam_output_reference(truncated_for(&secret, 1)));
assert_eq!(code_at_60, steam_output_reference(truncated_for(&secret, 2)));
assert_eq!(code_at_120, steam_output_reference(truncated_for(&secret, 4)));
}
#[test]
fn steam_output_is_exactly_5_chars_regardless_of_digits() {
let secret = b"hello world!".to_vec();
for digits in [4u8, 5, 6, 7, 8] {
let cfg = TotpConfig {
secret: Zeroizing::new(secret.clone()),
algorithm: TotpAlgorithm::Sha1,
digits,
period_seconds: 30,
kind: TotpKind::Steam,
};
let code = compute_totp_code(&cfg, 0).unwrap();
assert_eq!(code.len(), 5, "Steam output must be 5 chars (digits={})", digits);
}
}
#[test]
fn steam_output_uses_only_alphabet_chars() {
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
let secret = b"hello world!".to_vec();
let cfg = TotpConfig {
secret: Zeroizing::new(secret),
algorithm: TotpAlgorithm::Sha1,
digits: 5,
period_seconds: 30,
kind: TotpKind::Steam,
};
for t in 0u64..1000 {
let code = compute_totp_code(&cfg, t * 30).unwrap();
for ch in code.chars() {
assert!(ALPHA.contains(ch), "char {ch:?} not in Steam alphabet (t={t})");
}
}
}
#[test]
fn steam_alphabet_excludes_ambiguous_glyphs() {
const ALPHA: &str = "23456789BCDFGHJKMNPQRTVWXY";
for ch in ['0', 'O', '1', 'I', 'L', 'S', '5', 'A', 'Z'] {
assert!(!ALPHA.contains(ch), "ambiguous glyph {ch:?} must not be in alphabet");
}
}
}
```
- [ ] **Step 3: Run the new tests — they should fail**
Run: `cargo test -p relicario-core --lib item_types::totp::steam_tests 2>&1 | tail -20`
Expected: 3-4 failures (the alphabet-exclusion test passes trivially since it doesn't call the impl; the others all fail because Steam currently returns decimal output).
- [ ] **Step 4: Implement the Steam alphabet output in `compute_totp_code`**
In `crates/relicario-core/src/item_types/totp.rs`, near the top (after the `use` block), add the constant:
```rust
/// Steam Mobile Authenticator's 5-character output alphabet.
/// Deliberately excludes ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
const STEAM_ALPHABET: &[u8] = b"23456789BCDFGHJKMNPQRTVWXY";
```
Then in `compute_totp_code`, replace the final `let modulus = ...; Ok(format!(...))` block with:
```rust
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))
```
(Where `truncated` is the `u32` already computed by the existing dynamic-truncation logic above. If the existing code uses a different variable name, adapt accordingly — the variable representing the 31-bit truncated HMAC output.)
- [ ] **Step 5: Re-run all totp tests — Steam tests pass, decimal tests still pass**
Run: `cargo test -p relicario-core --lib item_types::totp 2>&1 | tail -10`
Expected: all tests in `compute_tests`, `tests`, and `steam_tests` modules pass. The pre-existing `rfc6238_sha1_vector_59` decimal test must still pass (assertion `code == "94287082"`).
- [ ] **Step 6: Run the whole workspace — no regressions**
Run: `cargo test --workspace 2>&1 | grep -E "test result"`
Expected: every line ends with `0 failed`. New tests bump total by 4.
- [ ] **Step 7: Commit**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1
git add crates/relicario-core/src/item_types/totp.rs
git commit -m "$(cat <<'EOF'
feat(core/totp): emit Steam Guard alphabet for kind=Steam
compute_totp_code previously produced decimal output for all three
TotpKind variants. Steam Guard requires a 5-character output drawn
from a 26-char alphabet (23456789BCDFGHJKMNPQRTVWXY) — deliberately
excluding ambiguous glyphs (0/O, 1/I/L, S/5, A/Z).
Implementation:
- Iterate the 31-bit truncated HMAC value 5 times: push
STEAM_ALPHABET[t % 26] then divide by 26.
- TOTP / HOTP decimal output paths unchanged.
Tests:
- steam_output_matches_reference_impl: cross-checks the production
impl against a separate reference implementation in the test
module (the algorithm is short enough that a parallel impl is the
cleanest spec).
- steam_output_is_exactly_5_chars_regardless_of_digits: Steam
ignores the `digits` field; output always 5 chars.
- steam_output_uses_only_alphabet_chars: 1000-iteration sweep
confirms no character outside the alphabet ever appears.
- steam_alphabet_excludes_ambiguous_glyphs.
- Existing RFC 6238 SHA1 test vector for kind=Totp still passes
byte-for-byte.
EOF
)"
```
---
## Slice 2 — Shared field helpers + Login refactor
Goal: introduce `popup/components/fields.ts` with `renderRow` / `renderConcealedRow` / `renderSignatureBlock` / `wireFieldHandlers`, add the supporting CSS, write helper unit tests, then refactor the existing Login detail/form code onto the helpers as the reference implementation.
### Task 2: Add the field helpers module + tests + CSS
**Files:**
- Create: `extension/src/popup/components/fields.ts`
- Create: `extension/src/popup/components/__tests__/fields.test.ts`
- Modify: `extension/src/popup/styles.css`
- [ ] **Step 1: Write the failing helper tests**
Create `extension/src/popup/components/__tests__/fields.test.ts`:
```ts
import { describe, expect, it, vi } from 'vitest';
import {
renderRow,
renderConcealedRow,
renderSignatureBlock,
wireFieldHandlers,
} from '../fields';
describe('renderRow', () => {
it('plain row contains label + value', () => {
const html = renderRow({ label: 'username', value: 'alice' });
expect(html).toContain('username');
expect(html).toContain('alice');
expect(html).toContain('field-row');
});
it('copyable row exposes a copy action', () => {
const html = renderRow({ label: 'email', value: 'alice@example.com', copyable: true });
expect(html).toContain('data-field-action="copy"');
});
it('href row wraps value in an external anchor', () => {
const html = renderRow({ label: 'url', value: 'https://example.com', href: 'https://example.com' });
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer"');
});
it('monospace flag toggles the monospace class', () => {
const html = renderRow({ label: 'fingerprint', value: 'AB:CD', monospace: true });
expect(html).toContain('monospace');
});
it('multiline value renders inside a <pre>', () => {
const html = renderRow({ label: 'address', value: '1 Main\n2 Main', multiline: true });
expect(html).toContain('<pre');
});
it('escapes HTML in value and label', () => {
const html = renderRow({ label: '<script>x</script>', value: '"&<>' });
expect(html).not.toContain('<script>');
expect(html).toContain('&amp;');
expect(html).toContain('&lt;');
});
});
describe('renderConcealedRow', () => {
it('initial state hides the value behind a placeholder', () => {
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
expect(html).toContain('data-field-id="pw1"');
expect(html).toContain('data-revealed="false"');
expect(html).toContain('••••');
// Plaintext is in a data attribute on the row, NOT in the visible textContent.
expect(html).not.toMatch(/>hunter2</);
});
it('exposes show + copy actions', () => {
const html = renderConcealedRow({ id: 'pw1', label: 'password', value: 'hunter2' });
expect(html).toContain('data-field-action="reveal"');
expect(html).toContain('data-field-action="copy"');
});
it('multiline concealed shows char count when hidden', () => {
const html = renderConcealedRow({ id: 'k1', label: 'key', value: 'abcdefghij', multiline: true });
expect(html).toContain('•••• (10 chars)');
});
});
describe('renderSignatureBlock', () => {
it('default accent is blue', () => {
const html = renderSignatureBlock({ children: '<p>hi</p>' });
expect(html).toContain('sig-block--blue');
expect(html).toContain('<p>hi</p>');
});
it('honors accent prop', () => {
expect(renderSignatureBlock({ accent: 'green', children: '' })).toContain('sig-block--green');
expect(renderSignatureBlock({ accent: 'amber', children: '' })).toContain('sig-block--amber');
expect(renderSignatureBlock({ accent: 'red', children: '' })).toContain('sig-block--red');
});
});
describe('wireFieldHandlers', () => {
it('reveal toggle flips data-revealed and swaps placeholder for plaintext', () => {
document.body.innerHTML = renderConcealedRow({
id: 'pw1',
label: 'password',
value: 'hunter2',
});
wireFieldHandlers(document.body);
const row = document.querySelector('[data-field-id="pw1"]') as HTMLElement;
const revealBtn = row.querySelector('[data-field-action="reveal"]') as HTMLButtonElement;
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement;
expect(row.getAttribute('data-revealed')).toBe('false');
expect(valueEl.textContent).toContain('••••');
revealBtn.click();
expect(row.getAttribute('data-revealed')).toBe('true');
expect(valueEl.textContent).toBe('hunter2');
});
it('copy button writes the row value to the clipboard', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
document.body.innerHTML = renderRow({
label: 'email',
value: 'alice@example.com',
copyable: true,
});
wireFieldHandlers(document.body);
const copyBtn = document.querySelector('[data-field-action="copy"]') as HTMLButtonElement;
copyBtn.click();
expect(writeText).toHaveBeenCalledWith('alice@example.com');
});
});
```
- [ ] **Step 2: Run — tests fail because fields.ts doesn't exist yet**
Run: `cd extension && bun run test 2>&1 | tail -10`
Expected: import errors / module-not-found for `../fields`.
- [ ] **Step 3: Create the helper module**
Create `extension/src/popup/components/fields.ts`:
```ts
/// Field rendering primitives used by every typed-item detail view.
///
/// Pure functions that return HTML strings. Caller is responsible for
/// mounting the strings into the DOM (typically via `app.innerHTML = ...`).
/// After mounting, call `wireFieldHandlers(scope)` once to bind reveal +
/// copy click handlers on any rendered rows.
import { escapeHtml } from '../popup';
export interface RowOpts {
label: string;
value: string;
copyable?: boolean;
href?: string;
monospace?: boolean;
multiline?: boolean;
}
/// Plain label/value row. Optional copy button, optional anchor wrap,
/// optional monospace styling, optional multiline (renders in a <pre>).
export function renderRow(opts: RowOpts): string {
const { label, value, copyable, href, monospace, multiline } = opts;
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
let valueHtml: string;
if (multiline) {
valueHtml = `<pre>${escapeHtml(value)}</pre>`;
} else if (href) {
valueHtml = `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(value)}</a>`;
} else {
valueHtml = escapeHtml(value);
}
const actions = copyable
? `<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>`
: '';
return `
<div class="field-row">
<span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${valueHtml}</span>
<span class="field-row__actions">${actions}</span>
</div>
`;
}
export interface ConcealedRowOpts {
id: string;
label: string;
value: string;
monospace?: boolean;
multiline?: boolean;
}
/// Concealed row — value rendered hidden until the user clicks "show".
/// Plaintext is stored in `data-field-value` on the row element and copied
/// to the visible value span on reveal. Copy button always copies plaintext.
export function renderConcealedRow(opts: ConcealedRowOpts): string {
const { id, label, value, monospace, multiline } = opts;
const placeholder = multiline ? `•••• (${value.length} chars)` : '••••';
const valueClass = `field-row__value${monospace ? ' monospace' : ''}`;
// Plaintext lives in the row's data-field-value attribute, not in the
// visible <span> textContent — the reveal handler swaps it in on click.
return `
<div class="field-row" data-field-id="${escapeHtml(id)}" data-revealed="false" data-field-value="${escapeHtml(value)}" data-field-multiline="${multiline ? 'true' : 'false'}">
<span class="field-row__label">${escapeHtml(label)}</span>
<span class="${valueClass}" data-field-role="value">${escapeHtml(placeholder)}</span>
<span class="field-row__actions">
<button type="button" data-field-action="reveal">show</button>
<button type="button" data-field-action="copy" data-field-value="${escapeHtml(value)}">copy</button>
</span>
</div>
`;
}
export interface SignatureBlockOpts {
accent?: 'blue' | 'green' | 'amber' | 'red';
children: string;
}
/// Container for the type-specific signature panel. `children` is HTML
/// the caller has already produced (and escaped where needed).
export function renderSignatureBlock(opts: SignatureBlockOpts): string {
const accent = opts.accent ?? 'blue';
return `
<div class="sig-block sig-block--${accent}">${opts.children}</div>
`;
}
/// Wire reveal-toggle + copy click handlers within `scope`. Idempotent —
/// safe to call multiple times against the same scope; existing handlers
/// are replaced by Element.addEventListener semantics on re-render.
export function wireFieldHandlers(scope: HTMLElement): void {
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="reveal"]').forEach((btn) => {
btn.addEventListener('click', () => {
const row = btn.closest('[data-field-id]') as HTMLElement | null;
if (!row) return;
const valueEl = row.querySelector('[data-field-role="value"]') as HTMLElement | null;
if (!valueEl) return;
const revealed = row.getAttribute('data-revealed') === 'true';
const plaintext = row.getAttribute('data-field-value') ?? '';
const multiline = row.getAttribute('data-field-multiline') === 'true';
if (revealed) {
const placeholder = multiline ? `•••• (${plaintext.length} chars)` : '••••';
valueEl.textContent = placeholder;
row.setAttribute('data-revealed', 'false');
btn.textContent = 'show';
} else {
valueEl.textContent = plaintext;
row.setAttribute('data-revealed', 'true');
btn.textContent = 'hide';
}
});
});
scope.querySelectorAll<HTMLButtonElement>('[data-field-action="copy"]').forEach((btn) => {
btn.addEventListener('click', async () => {
const value = btn.getAttribute('data-field-value') ?? '';
try { await navigator.clipboard.writeText(value); } catch { /* swallow — UX is the visual flash below */ }
const original = btn.textContent;
btn.textContent = 'copied';
setTimeout(() => { if (btn.textContent === 'copied') btn.textContent = original; }, 1500);
});
});
}
```
- [ ] **Step 4: Add the supporting CSS**
Append to `extension/src/popup/styles.css`:
```css
/* --- field-row + signature-block helpers (β₁) --- */
.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; word-break: break-word; }
.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;
font-family: "SF Mono", "JetBrains Mono", monospace;
}
.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; }
```
- [ ] **Step 5: Re-run helper tests — should pass**
Run: `cd extension && bun run test src/popup/components/__tests__/fields.test.ts 2>&1 | tail -10`
Expected: all 14 helper tests pass.
- [ ] **Step 6: Verify build still passes**
Run: `cd extension && bun run build 2>&1 | tail -3`
Expected: `compiled with 2 warnings` (existing WASM size warnings only).
- [ ] **Step 7: Commit**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1
git add extension/src/popup/components/fields.ts \
extension/src/popup/components/__tests__/fields.test.ts \
extension/src/popup/styles.css
git commit -m "feat(ext/popup): field-row + concealed-row + signature-block helpers"
```
### Task 3: Extract Login to `types/login.ts` using the helpers
**Files:**
- Create: `extension/src/popup/components/types/login.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Read the existing Login branches**
Read `extension/src/popup/components/item-detail.ts` and `item-form.ts`. Identify the Login-rendering code in each (search for `case 'login':` or `renderLoginDetail` / `renderLoginForm` / `saveLogin`).
- [ ] **Step 2: Create the Login type module**
Create `extension/src/popup/components/types/login.ts`. The body has three exported functions; the bodies are the existing Login-branch code from `item-detail.ts` and `item-form.ts`, lifted verbatim BUT with the row-rendering replaced by `renderRow` / `renderConcealedRow` / `renderSignatureBlock` calls.
```ts
/// Login type detail + form. Reference implementation for the shared
/// field helpers introduced in Slice 2.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, LoginCore, ManifestEntry, TotpConfig } from '../../../shared/types';
import { DEFAULT_PASSWORD_REQUEST } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {
renderRow,
renderConcealedRow,
renderSignatureBlock,
wireFieldHandlers,
} from '../fields';
// ----------------------------------------------------------------------
// Detail view
// ----------------------------------------------------------------------
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'login') return;
const core = item.core as LoginCore & { type: 'login' };
const password = core.password ?? '';
const username = core.username ?? '';
const url = core.url ?? '';
const hasTotp = core.totp !== undefined;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
${url ? `<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer" style="font-size:11px;color:#58a6ff;">open ↗</a>` : ''}
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${username ? renderRow({ label: 'username', value: username, copyable: true }) : ''}
${renderConcealedRow({ id: 'login-password', label: 'password', value: password })}
${url ? renderRow({ label: 'url', value: url, href: url }) : ''}
${hasTotp ? `
<div class="field-row">
<span class="field-row__label">totp</span>
<span class="field-row__value monospace" id="totp-code">…</span>
<span class="field-row__actions"><span id="totp-countdown" style="font-variant-numeric:tabular-nums;">…</span></span>
</div>
` : ''}
${item.notes ? renderRow({ label: 'notes', value: item.notes, multiline: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn" id="fill-btn">autofill</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
document.getElementById('fill-btn')?.addEventListener('click', async () => {
const { capturedTabId, capturedUrl } = getState();
if (capturedTabId === null) { setState({ error: 'No active tab captured' }); return; }
const resp = await sendMessage({
type: 'fill_credentials', id: item.id, capturedTabId, capturedUrl,
});
if (!resp.ok) setState({ error: resp.error });
else window.close();
});
if (hasTotp) startTotpTicker(item.id);
const handler = (e: KeyboardEvent) => {
const t = e.target;
if (t instanceof HTMLElement) {
const tag = t.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || t.isContentEditable) return;
}
if (e.key === 'Escape') {
document.removeEventListener('keydown', handler);
stopTotpTicker();
navigate('list');
}
};
document.addEventListener('keydown', handler);
}
// ----------------------------------------------------------------------
// TOTP ticker
// ----------------------------------------------------------------------
let totpTickerId: ReturnType<typeof setInterval> | null = null;
function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
}
function startTotpTicker(id: ItemId): void {
stopTotpTicker();
const tick = async () => {
const r = await sendMessage({ type: 'get_totp', id });
if (!r.ok) return;
const { code, expires_at } = r.data as { code: string; expires_at: number };
const codeEl = document.getElementById('totp-code');
const cdEl = document.getElementById('totp-countdown');
if (codeEl) codeEl.textContent = code;
if (cdEl) cdEl.textContent = `${Math.max(0, expires_at - Math.floor(Date.now() / 1000))}s`;
};
void tick();
totpTickerId = setInterval(() => void tick(), 1000);
}
// ----------------------------------------------------------------------
// Form (add / edit)
// ----------------------------------------------------------------------
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const existingCore = (existing?.core.type === 'login')
? (existing.core as LoginCore & { type: 'login' })
: null;
const title = existing?.title ?? '';
const url = existingCore?.url ?? '';
const username = existingCore?.username ?? '';
const password = existingCore?.password ?? '';
const totpStr = existingCore?.totp ? base32Encode(new Uint8Array(existingCore.totp.secret)) : '';
const group = existing?.group ?? '';
const notes = existing?.notes ?? '';
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new login' : 'edit login'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"><label class="label" for="f-url">url</label>
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></div>
<div class="form-group"><label class="label" for="f-username">username</label>
<input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>
<div class="form-group"><label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}">
<button class="btn" id="gen-btn" title="generate">gen</button>
</div></div>
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label>
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<div class="form-group"><label class="label" for="f-group">group</label>
<input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>
<div class="form-group"><label class="label" for="f-notes">notes</label>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
</div>
</div>
`;
document.getElementById('gen-btn')?.addEventListener('click', async () => {
const resp = await sendMessage({ type: 'generate_password', request: DEFAULT_PASSWORD_REQUEST });
if (resp.ok) {
const data = resp.data as { password: string };
const pw = document.getElementById('f-password') as HTMLInputElement;
pw.value = data.password;
pw.type = 'text';
} else setState({ error: resp.error });
});
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveLogin(mode, existing);
});
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
document.removeEventListener('keydown', escHandler);
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
}
};
document.addEventListener('keydown', escHandler);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
function normalizeUrl(raw: string): { ok: true; value: string } | { ok: false; error: string } {
if (!raw) return { ok: true, value: '' };
const trimmed = raw.trim();
const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`;
try {
const u = new URL(candidate);
if (!u.host) return { ok: false, error: 'URL must include a host (e.g. https://example.com)' };
return { ok: true, value: u.toString() };
} catch {
return { ok: false, error: 'URL is not valid — try something like https://example.com' };
}
}
async function saveLogin(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const rawUrl = (document.getElementById('f-url') as HTMLInputElement).value;
const username = (document.getElementById('f-username') as HTMLInputElement).value.trim();
const password = (document.getElementById('f-password') as HTMLInputElement).value;
const totpStr = (document.getElementById('f-totp') as HTMLInputElement).value.trim();
const group = (document.getElementById('f-group') as HTMLInputElement).value.trim();
const notes = (document.getElementById('f-notes') as HTMLTextAreaElement).value;
if (!title) { setState({ error: 'Title is required' }); return; }
const urlResult = normalizeUrl(rawUrl);
if (!urlResult.ok) { setState({ error: urlResult.error }); return; }
const url = urlResult.value;
let totp: TotpConfig | undefined;
if (totpStr) {
try {
const bytes = base32Decode(totpStr);
totp = {
secret: Array.from(bytes),
algorithm: 'sha1', digits: 6, period_seconds: 30, kind: 'totp',
};
} catch (err) {
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
return;
}
}
const now = Math.floor(Date.now() / 1000);
const core: LoginCore & { type: 'login' } = {
type: 'login',
username: username || undefined,
password: password || undefined,
url: url || undefined,
totp,
};
const item: Item = {
id: existing?.id ?? '',
title, type: 'login',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: group || undefined,
notes: notes || undefined,
created: existing?.created ?? now,
modified: now,
trashed_at: undefined,
core,
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 3: Update `item-detail.ts` to dispatch into `types/login`**
Replace the body of `extension/src/popup/components/item-detail.ts` with:
```ts
/// Typed-item detail view dispatcher. Each type's renderDetail lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate } from '../popup';
import type { Item } from '../../shared/types';
import { getState } from '../popup';
import * as login from './types/login';
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':
case 'identity':
case 'card':
case 'key':
case 'totp':
case 'document': return renderComingSoon(app, item);
}
}
function renderComingSoon(app: HTMLElement, item: Item): void {
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${item.title}</div>
<p class="muted">The <strong>${item.type}</strong> item type is not editable in the extension yet.</p>
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
</div>
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}
```
- [ ] **Step 4: Update `item-form.ts` to dispatch into `types/login`**
Replace the body of `extension/src/popup/components/item-form.ts` with:
```ts
/// Typed-item add/edit form dispatcher. Each type's renderForm lives in
/// its own module under ./types/. Document stays "coming soon" until γ.
import { navigate, getState } from '../popup';
import type { Item, ItemType } from '../../shared/types';
import * as login from './types/login';
export function renderItemForm(app: HTMLElement, mode: 'add' | 'edit'): void {
const state = getState();
const existing = mode === 'edit' ? state.selectedItem : null;
const type: ItemType = existing?.type ?? state.newType ?? 'login';
switch (type) {
case 'login': return login.renderForm(app, mode, existing);
case 'secure_note':
case 'identity':
case 'card':
case 'key':
case 'totp':
case 'document': return renderComingSoon(app, type);
}
}
function renderComingSoon(app: HTMLElement, type: ItemType): void {
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${type.replace('_', ' ')}</div>
<p class="muted">Editing <strong>${type}</strong> items is not available yet.</p>
<div class="form-actions"><button class="btn" id="back-btn">back</button></div>
</div>
`;
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
}
```
- [ ] **Step 5: Add `newType` to PopupState**
In `extension/src/popup/popup.ts`, find the `PopupState` interface and add:
```ts
newType: import('../shared/types').ItemType | null;
```
Find the `currentState` initializer and add:
```ts
newType: null,
```
(The "+ New" picker in Task 13 will set this; until then it stays null and the form defaults to 'login' as before.)
- [ ] **Step 6: Verify build + existing tests still pass**
```bash
cd extension
bun run build 2>&1 | tail -3
bun run test 2>&1 | tail -10
```
Expected: both green; test count is now `55 + 14 = 69`.
- [ ] **Step 7: Commit**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1
git add extension/src/popup/components/types/login.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts \
extension/src/popup/popup.ts
git commit -m "refactor(ext/popup): extract Login to types/login.ts on shared helpers"
```
---
## Slice 3 — SecureNote + Identity
### Task 4: SecureNote — view + form + tests
**Files:**
- Create: `extension/src/popup/components/types/secure-note.ts`
- Create: `extension/src/popup/components/types/__tests__/secure-note.save.test.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Write the failing save-shape test**
Create `extension/src/popup/components/types/__tests__/secure-note.save.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Hoisted mocks — vitest hoists vi.mock above imports.
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'secure_note',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../secure-note';
import { sendMessage } from '../../../popup';
describe('SecureNote save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with type=secure_note and the body in core', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'My Secret Note';
(document.getElementById('f-body') as HTMLTextAreaElement).value = 'hello\nworld';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeDefined();
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.title).toBe('My Secret Note');
expect(msg.item.type).toBe('secure_note');
expect(msg.item.core).toEqual({ type: 'secure_note', body: 'hello\nworld' });
expect(msg.item.trashed_at).toBeUndefined();
expect(msg.item.sections).toEqual([]);
expect(msg.item.attachments).toEqual([]);
});
});
```
- [ ] **Step 2: Run — fails (module missing)**
Run: `cd extension && bun run test src/popup/components/types/__tests__/secure-note.save.test.ts 2>&1 | tail -10`
Expected: import error for `../secure-note`.
- [ ] **Step 3: Implement `secure-note.ts`**
Create `extension/src/popup/components/types/secure-note.ts`:
```ts
/// SecureNote: a single multiline body field. Concealed by default in the
/// detail view; the form is just a big <textarea>.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderConcealedRow,
renderSignatureBlock,
wireFieldHandlers,
} from '../fields';
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'secure_note') return;
const body = item.core.body ?? '';
const sigInner = `
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'green', children: sigInner })}
</div>
${renderConcealedRow({ id: 'note-body', label: 'body', value: body, multiline: true })}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const body = (existing?.core.type === 'secure_note') ? existing.core.body ?? '' : '';
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new secure note' : 'edit secure note'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="My recovery codes"></div>
<div class="form-group"><label class="label" for="f-body">body</label>
<textarea id="f-body" rows="10" placeholder="paste secrets here">${escapeHtml(body)}</textarea></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveSecureNote(mode, existing);
});
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveSecureNote(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
const body = (document.getElementById('f-body') as HTMLTextAreaElement).value;
if (!title) { setState({ error: 'Title is required' }); return; }
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'secure_note',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group,
notes: existing?.notes,
created: existing?.created ?? now,
modified: now,
trashed_at: undefined,
core: { type: 'secure_note', body },
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 4: Wire SecureNote into the dispatchers**
In `extension/src/popup/components/item-detail.ts`:
- Add `import * as secureNote from './types/secure-note';` after the login import.
- Replace `case 'secure_note':` (currently falls through to coming-soon) with `return secureNote.renderDetail(app, item);`.
In `extension/src/popup/components/item-form.ts`:
- Add `import * as secureNote from './types/secure-note';` after the login import.
- Replace `case 'secure_note':` with `return secureNote.renderForm(app, mode, existing);`.
- [ ] **Step 5: Run tests — should pass**
```bash
cd extension && bun run test 2>&1 | tail -5
```
Expected: count = `69 + 1 = 70` passing.
- [ ] **Step 6: Verify build**
Run: `cd extension && bun run build 2>&1 | tail -3`
Expected: clean.
- [ ] **Step 7: Commit**
```bash
git add extension/src/popup/components/types/secure-note.ts \
extension/src/popup/components/types/__tests__/secure-note.save.test.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): SecureNote view + form on shared helpers"
```
### Task 5: Identity — view + form + tests
**Files:**
- Create: `extension/src/popup/components/types/identity.ts`
- Create: `extension/src/popup/components/types/__tests__/identity.save.test.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Write the failing save-shape test**
Create `extension/src/popup/components/types/__tests__/identity.save.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'identity',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../identity';
import { sendMessage } from '../../../popup';
describe('Identity save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with all populated fields and undefined for blanks', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Aaron Lee · personal';
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Aaron Lee';
(document.getElementById('f-email') as HTMLInputElement).value = 'aaron@example.com';
(document.getElementById('f-phone') as HTMLInputElement).value = '+1 555 0100';
(document.getElementById('f-address') as HTMLTextAreaElement).value = '1 Main St\nSpringfield';
(document.getElementById('f-dob') as HTMLInputElement).value = '1985-05-23';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('identity');
expect(msg.item.core).toEqual({
type: 'identity',
full_name: 'Aaron Lee',
email: 'aaron@example.com',
phone: '+1 555 0100',
address: '1 Main St\nSpringfield',
date_of_birth: '1985-05-23',
});
});
it('leaves empty fields out of core entirely', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'name only';
(document.getElementById('f-full-name') as HTMLInputElement).value = 'Bob';
// Other fields left blank.
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.full_name).toBe('Bob');
expect(msg.item.core.email).toBeUndefined();
expect(msg.item.core.phone).toBeUndefined();
expect(msg.item.core.address).toBeUndefined();
expect(msg.item.core.date_of_birth).toBeUndefined();
});
});
```
- [ ] **Step 2: Run — fails (module missing)**
Run: `cd extension && bun run test src/popup/components/types/__tests__/identity.save.test.ts 2>&1 | tail -5`
Expected: import error.
- [ ] **Step 3: Implement `identity.ts`**
Create `extension/src/popup/components/types/identity.ts`:
```ts
/// Identity: full_name, address (multiline), phone, email, date_of_birth.
/// Detail view shows a "profile card" signature block + plain rows.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
function initials(name: string | undefined): string {
if (!name) return '?';
const parts = name.trim().split(/\s+/).slice(0, 2);
return parts.map((p) => p.charAt(0).toUpperCase()).join('') || '?';
}
function formatDate(iso: string | undefined): string {
if (!iso) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
if (!m) return iso;
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' });
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'identity') return;
const c = item.core;
const sigInner = `
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:36px;height:36px;border-radius:50%;background:#d29922;color:#0d1117;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;">${escapeHtml(initials(c.full_name))}</div>
<div>
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(c.full_name ?? item.title)}</div>
${c.email ? `<div style="font-size:11px;color:#8b949e;">${escapeHtml(c.email)}</div>` : ''}
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'amber', children: sigInner })}
</div>
${c.phone ? renderRow({ label: 'phone', value: c.phone, copyable: true }) : ''}
${c.email ? renderRow({ label: 'email', value: c.email, copyable: true }) : ''}
${c.address ? renderRow({ label: 'address', value: c.address, multiline: true }) : ''}
${c.date_of_birth ? renderRow({ label: 'born', value: formatDate(c.date_of_birth) }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'identity') ? existing.core : null;
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new identity' : 'edit identity'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Aaron Lee · personal"></div>
<div class="form-group"><label class="label" for="f-full-name">full name</label>
<input id="f-full-name" type="text" value="${escapeHtml(c?.full_name ?? '')}" placeholder="Aaron Lee"></div>
<div class="form-group"><label class="label" for="f-email">email</label>
<input id="f-email" type="email" value="${escapeHtml(c?.email ?? '')}" placeholder="aaron@example.com"></div>
<div class="form-group"><label class="label" for="f-phone">phone</label>
<input id="f-phone" type="tel" value="${escapeHtml(c?.phone ?? '')}" placeholder="+1 555 0100"></div>
<div class="form-group"><label class="label" for="f-address">address</label>
<textarea id="f-address" rows="3" placeholder="street, city, postcode...">${escapeHtml(c?.address ?? '')}</textarea></div>
<div class="form-group"><label class="label" for="f-dob">date of birth</label>
<input id="f-dob" type="date" value="${escapeHtml(c?.date_of_birth ?? '')}"></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveIdentity(mode, existing);
});
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveIdentity(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
const core = {
type: 'identity' as const,
full_name: get('f-full-name') || undefined,
email: get('f-email') || undefined,
phone: get('f-phone') || undefined,
address: get('f-address') || undefined,
date_of_birth: get('f-dob') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'identity',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
created: existing?.created ?? now,
modified: now, trashed_at: undefined,
core,
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 2 alt: Wire Identity into the dispatchers**
In `item-detail.ts`: add `import * as identity from './types/identity';` and replace `case 'identity':` with `return identity.renderDetail(app, item);`.
In `item-form.ts`: same — add the import, replace `case 'identity':` with `return identity.renderForm(app, mode, existing);`.
- [ ] **Step 3: Run tests + build**
```bash
cd extension && bun run test 2>&1 | tail -5
cd extension && bun run build 2>&1 | tail -3
```
Expected: 72 tests pass; build clean.
- [ ] **Step 4: Commit**
```bash
git add extension/src/popup/components/types/identity.ts \
extension/src/popup/components/types/__tests__/identity.save.test.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Identity view + form (profile-card signature block)"
```
---
## Slice 4 — Card + Key
### Task 6: Card — view + form + tests
**Files:**
- Create: `extension/src/popup/components/types/card.ts`
- Create: `extension/src/popup/components/types/__tests__/card.save.test.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Write the failing save-shape test**
Create `extension/src/popup/components/types/__tests__/card.save.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'card',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../card';
import { sendMessage } from '../../../popup';
describe('Card save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('builds an Item with expiry as { month, year } and kind from select', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Amex Gold';
(document.getElementById('f-number') as HTMLInputElement).value = '378282246310005';
(document.getElementById('f-holder') as HTMLInputElement).value = 'AARON LEE';
(document.getElementById('f-expiry-month') as HTMLSelectElement).value = '08';
(document.getElementById('f-expiry-year') as HTMLSelectElement).value = '2029';
(document.getElementById('f-cvv') as HTMLInputElement).value = '1234';
(document.getElementById('f-pin') as HTMLInputElement).value = '5678';
(document.getElementById('f-kind') as HTMLSelectElement).value = 'credit';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('card');
expect(msg.item.core).toMatchObject({
type: 'card',
number: '378282246310005',
holder: 'AARON LEE',
expiry: { month: 8, year: 2029 },
cvv: '1234',
pin: '5678',
kind: 'credit',
});
});
it('omits expiry entirely when month or year is empty', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Loyalty card';
(document.getElementById('f-kind') as HTMLSelectElement).value = 'loyalty';
// expiry-month + expiry-year left empty.
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.expiry).toBeUndefined();
expect(msg.item.core.kind).toBe('loyalty');
});
});
```
- [ ] **Step 2: Run — fails (module missing)**
Run: `cd extension && bun run test src/popup/components/types/__tests__/card.save.test.ts 2>&1 | tail -5`
- [ ] **Step 3: Implement `card.ts`**
Create `extension/src/popup/components/types/card.ts`:
```ts
/// Card: number / holder / expiry MonthYear / cvv / pin / kind.
/// Detail view has a styled card-silhouette signature block.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry, CardKind } from '../../../shared/types';
import {
renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
const CARD_KINDS: CardKind[] = ['credit', 'debit', 'gift', 'loyalty', 'other'];
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 '';
}
function maskedNumber(num: string): string {
if (!num) return '';
const last4 = num.slice(-4);
const groups = num.length > 4 ? '•••• •••• •••• ' : '';
return `${groups}${last4}`;
}
function formatExpiry(e: { month: number; year: number } | undefined): string {
if (!e) return '';
const mm = String(e.month).padStart(2, '0');
const yy = String(e.year).slice(-2);
return `${mm}/${yy}`;
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'card') return;
const c = item.core;
const number = c.number ?? '';
const brand = brandFromNumber(number);
const kindLabel = (c.kind ?? 'other').toUpperCase();
const bandLabel = brand ? `${brand} · ${kindLabel}` : kindLabel;
const sigInner = `
<div style="font-size:9px;letter-spacing:0.1em;color:#6e7681;margin-bottom:6px;">${escapeHtml(bandLabel)}</div>
<div style="font-family:monospace;font-size:14px;letter-spacing:0.08em;color:#c9d1d9;margin-bottom:12px;display:flex;justify-content:space-between;align-items:center;" data-field-id="card-number" data-revealed="false" data-field-value="${escapeHtml(number)}" data-field-multiline="false">
<span data-field-role="value">${escapeHtml(maskedNumber(number))}</span>
${number ? '<button type="button" data-field-action="reveal" style="font-size:10px;color:#8b949e;cursor:pointer;font-family:system-ui,sans-serif;letter-spacing:0;background:transparent;border:0;">show</button>' : ''}
</div>
<div style="display:flex;justify-content:space-between;align-items:flex-end;">
<div>
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">HOLDER</div>
<div style="font-size:11px;color:#c9d1d9;">${escapeHtml(c.holder ?? '')}</div>
</div>
<div style="text-align:right;">
<div style="font-size:9px;color:#6e7681;letter-spacing:0.08em;">EXPIRES</div>
<div style="font-family:monospace;font-size:11px;color:#c9d1d9;">${escapeHtml(formatExpiry(c.expiry))}</div>
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${c.cvv ? renderConcealedRow({ id: 'card-cvv', label: 'cvv', value: c.cvv, monospace: true }) : ''}
${c.pin ? renderConcealedRow({ id: 'card-pin', label: 'pin', value: c.pin, monospace: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
// The card-number reveal lives inside the signature block, so wireFieldHandlers
// picks it up alongside the cvv/pin rows.
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'card') ? existing.core : null;
const currentYear = new Date().getFullYear();
const monthOptions = Array.from({ length: 12 }, (_, i) => {
const m = String(i + 1).padStart(2, '0');
const sel = c?.expiry?.month === i + 1 ? 'selected' : '';
return `<option value="${m}" ${sel}>${m}</option>`;
}).join('');
const yearOptions = Array.from({ length: 51 }, (_, i) => {
const y = currentYear - 25 + i;
const sel = c?.expiry?.year === y ? 'selected' : '';
return `<option value="${y}" ${sel}>${y}</option>`;
}).join('');
const kindOptions = CARD_KINDS.map((k) => {
const sel = (c?.kind ?? 'credit') === k ? 'selected' : '';
return `<option value="${k}" ${sel}>${k}</option>`;
}).join('');
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new card' : 'edit card'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="Amex Gold"></div>
<div class="form-group"><label class="label" for="f-number">number</label>
<input id="f-number" type="text" inputmode="numeric" value="${escapeHtml(c?.number ?? '')}" placeholder="378282246310005"></div>
<div class="form-group"><label class="label" for="f-holder">holder</label>
<input id="f-holder" type="text" value="${escapeHtml(c?.holder ?? '')}" placeholder="AARON LEE"></div>
<div class="form-group"><label class="label">expiry</label>
<div class="inline-row">
<select id="f-expiry-month"><option value="">mm</option>${monthOptions}</select>
<select id="f-expiry-year"><option value="">yyyy</option>${yearOptions}</select>
</div></div>
<div class="form-group"><label class="label" for="f-cvv">cvv</label>
<input id="f-cvv" type="password" inputmode="numeric" maxlength="4" value="${escapeHtml(c?.cvv ?? '')}"></div>
<div class="form-group"><label class="label" for="f-pin">pin</label>
<input id="f-pin" type="password" inputmode="numeric" maxlength="8" value="${escapeHtml(c?.pin ?? '')}"></div>
<div class="form-group"><label class="label" for="f-kind">kind</label>
<select id="f-kind">${kindOptions}</select></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveCard(mode, existing);
});
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveCard(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement).value.trim();
const number = get('f-number');
const holder = get('f-holder');
const expMonth = get('f-expiry-month');
const expYear = get('f-expiry-year');
const cvv = get('f-cvv');
const pin = get('f-pin');
const kind = (get('f-kind') as CardKind) || 'credit';
const expiry = (expMonth && expYear)
? { month: Number(expMonth), year: Number(expYear) }
: undefined;
const core = {
type: 'card' as const,
number: number || undefined,
holder: holder || undefined,
expiry,
cvv: cvv || undefined,
pin: pin || undefined,
kind,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'card',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
created: existing?.created ?? now,
modified: now, trashed_at: undefined,
core,
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 4: Wire Card into the dispatchers**
In `item-detail.ts`: add `import * as card from './types/card';` and replace `case 'card':` with `return card.renderDetail(app, item);`.
In `item-form.ts`: same — replace `case 'card':` with `return card.renderForm(app, mode, existing);`.
- [ ] **Step 5: Run tests + build**
```bash
cd extension && bun run test 2>&1 | tail -5
cd extension && bun run build 2>&1 | tail -3
```
Expected: 74 tests; build clean.
- [ ] **Step 6: Commit**
```bash
git add extension/src/popup/components/types/card.ts \
extension/src/popup/components/types/__tests__/card.save.test.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Card view + form (card-silhouette signature, MM/YY selects)"
```
### Task 7: Key — view + form + tests
**Files:**
- Create: `extension/src/popup/components/types/key.ts`
- Create: `extension/src/popup/components/types/__tests__/key.save.test.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Write the failing save-shape test**
Create `extension/src/popup/components/types/__tests__/key.save.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'key',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../key';
import { sendMessage } from '../../../popup';
describe('Key save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('requires key_material and emits all populated fields', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'github ssh';
(document.getElementById('f-key-material') as HTMLTextAreaElement).value = '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...';
(document.getElementById('f-label') as HTMLInputElement).value = 'work laptop';
(document.getElementById('f-public-key') as HTMLTextAreaElement).value = 'ssh-ed25519 AAAA...';
(document.getElementById('f-algorithm') as HTMLInputElement).value = 'ed25519';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('key');
expect(msg.item.core).toEqual({
type: 'key',
key_material: '-----BEGIN OPENSSH PRIVATE KEY-----\nfoo\n-----END...',
label: 'work laptop',
public_key: 'ssh-ed25519 AAAA...',
algorithm: 'ed25519',
});
});
it('rejects empty key_material', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'no key';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
});
```
- [ ] **Step 2: Run — fails (module missing)**
- [ ] **Step 3: Implement `key.ts`**
Create `extension/src/popup/components/types/key.ts`:
```ts
/// Key: key_material (required, concealed multiline) + label/algorithm/public_key.
/// Form's key_material textarea uses CSS text-security to mask characters
/// since <textarea type="password"> isn't a thing.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry } from '../../../shared/types';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'key') return;
const c = item.core;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div style="font-size:14px;font-weight:600;color:#c9d1d9;">${escapeHtml(item.title)}</div>
${c.algorithm ? `<div style="font-size:10px;color:#8b949e;font-family:monospace;">${escapeHtml(c.algorithm)}</div>` : ''}
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
${renderSignatureBlock({ accent: 'green', children: sigInner })}
</div>
${renderConcealedRow({ id: 'key-material', label: 'private', value: c.key_material, multiline: true, monospace: true })}
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
${c.algorithm ? renderRow({ label: 'algorithm', value: c.algorithm }) : ''}
${c.public_key ? renderRow({ label: 'public', value: c.public_key, multiline: true, monospace: true, copyable: true }) : ''}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('edit-btn')?.addEventListener('click', () => navigate('edit'));
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
}
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'key') ? existing.core : null;
app.innerHTML = `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new key' : 'edit key'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="github ssh"></div>
<div class="form-group"><label class="label" for="f-key-material">key material *</label>
<div style="position:relative;">
<textarea id="f-key-material" rows="8" style="font-family:monospace;-webkit-text-security:disc;" placeholder="paste key here">${escapeHtml(c?.key_material ?? '')}</textarea>
<button type="button" id="key-show-btn" class="btn" style="position:absolute;right:6px;top:6px;font-size:10px;padding:2px 8px;">show</button>
</div></div>
<div class="form-group"><label class="label" for="f-label">label</label>
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="work laptop"></div>
<div class="form-group"><label class="label" for="f-algorithm">algorithm</label>
<input id="f-algorithm" type="text" value="${escapeHtml(c?.algorithm ?? '')}" placeholder="ed25519"></div>
<div class="form-group"><label class="label" for="f-public-key">public key</label>
<textarea id="f-public-key" rows="4" style="font-family:monospace;" placeholder="ssh-ed25519 AAAA...">${escapeHtml(c?.public_key ?? '')}</textarea></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
// Show/hide toggle for the key_material textarea.
let revealed = false;
document.getElementById('key-show-btn')?.addEventListener('click', () => {
revealed = !revealed;
const ta = document.getElementById('f-key-material') as HTMLTextAreaElement;
ta.style.webkitTextSecurity = revealed ? 'none' : 'disc';
(document.getElementById('key-show-btn') as HTMLButtonElement).textContent = revealed ? 'hide' : 'show';
});
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveKey(mode, existing);
});
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
async function saveKey(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const keyMaterial = (document.getElementById('f-key-material') as HTMLTextAreaElement).value;
if (!keyMaterial) { setState({ error: 'Key material is required' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement | HTMLTextAreaElement).value.trim();
const core = {
type: 'key' as const,
key_material: keyMaterial,
label: get('f-label') || undefined,
public_key: get('f-public-key') || undefined,
algorithm: get('f-algorithm') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'key',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
created: existing?.created ?? now,
modified: now, trashed_at: undefined,
core,
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 4: Wire Key into the dispatchers**
In `item-detail.ts`: add `import * as key from './types/key';` and replace `case 'key':` with `return key.renderDetail(app, item);`.
In `item-form.ts`: same — replace `case 'key':` with `return key.renderForm(app, mode, existing);`.
- [ ] **Step 5: Run tests + build**
```bash
cd extension && bun run test 2>&1 | tail -5
cd extension && bun run build 2>&1 | tail -3
```
Expected: 76 tests pass; build clean.
- [ ] **Step 6: Commit**
```bash
git add extension/src/popup/components/types/key.ts \
extension/src/popup/components/types/__tests__/key.save.test.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Key view + form (concealed monospace signature block)"
```
---
## Slice 5 — Totp (incl. Steam toggle)
### Task 8: Totp — view + form + tests
**Files:**
- Create: `extension/src/popup/components/types/totp.ts`
- Create: `extension/src/popup/components/types/__tests__/totp.save.test.ts`
- Modify: `extension/src/popup/components/item-detail.ts`
- Modify: `extension/src/popup/components/item-form.ts`
- [ ] **Step 1: Write the failing save-shape test (covers TOTP and Steam)**
Create `extension/src/popup/components/types/__tests__/totp.save.test.ts`:
```ts
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../popup', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'totp',
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
return { navigate, setState, sendMessage, getState, escapeHtml };
});
import { renderForm } from '../totp';
import { sendMessage } from '../../../popup';
import { base32Decode } from '../../../../shared/base32';
describe('Totp save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { id: 'fakeid0000000000', items: [] } });
});
it('TOTP kind: secret round-trips via base32, defaults applied', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
(document.getElementById('f-issuer') as HTMLInputElement).value = 'GitHub';
(document.getElementById('f-label') as HTMLInputElement).value = 'alice';
// Default kind is 'totp', no toggle click needed.
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('totp');
expect(msg.item.core).toMatchObject({
type: 'totp',
issuer: 'GitHub',
label: 'alice',
config: {
secret: Array.from(base32Decode('JBSWY3DPEHPK3PXP')),
algorithm: 'sha1',
digits: 6,
period_seconds: 30,
kind: 'totp',
},
});
});
it('Steam kind: digits set to 5, kind set to steam', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'Steam';
(document.getElementById('f-secret') as HTMLInputElement).value = 'JBSWY3DPEHPK3PXP';
// Click the Steam toggle button.
(document.getElementById('kind-steam') as HTMLButtonElement).click();
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.core.config).toMatchObject({
digits: 5,
kind: 'steam',
algorithm: 'sha1',
period_seconds: 30,
});
});
it('rejects empty secret', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'no secret';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
});
```
- [ ] **Step 2: Run — fails (module missing)**
- [ ] **Step 3: Implement `totp.ts`**
Create `extension/src/popup/components/types/totp.ts`:
```ts
/// Totp standalone item type. Detail view shows the rotating code in a
/// signature block with a thin SVG countdown ring; form has a kind toggle
/// (TOTP vs Steam Guard) and a single secret input.
import { getState, setState, sendMessage, navigate, escapeHtml } from '../../popup';
import type { Item, ItemId, ManifestEntry, TotpKind } from '../../../shared/types';
import { base32Decode, base32Encode } from '../../../shared/base32';
import {
renderRow, renderConcealedRow, renderSignatureBlock, wireFieldHandlers,
} from '../fields';
let totpTickerId: ReturnType<typeof setInterval> | null = null;
function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
}
export async function renderDetail(app: HTMLElement, item: Item): Promise<void> {
if (item.core.type !== 'totp') return;
const c = item.core;
const secretB32 = base32Encode(new Uint8Array(c.config.secret));
const isSteam = c.config.kind === 'steam';
const headerLine = c.issuer
? `${escapeHtml(c.issuer)}${c.label ? ` · ${escapeHtml(c.label)}` : ''}`
: escapeHtml(item.title);
// Countdown ring SVG. Stroke-dashoffset animates per tick.
const ringSvg = `
<svg width="32" height="32" viewBox="0 0 32 32" style="display:block;">
<circle cx="16" cy="16" r="14" stroke="#30363d" stroke-width="2" fill="none"/>
<circle id="totp-ring-arc" cx="16" cy="16" r="14" stroke="#58a6ff" stroke-width="2" fill="none"
stroke-linecap="round" stroke-dasharray="87.96"
transform="rotate(-90 16 16)" style="transition:stroke-dashoffset 1s linear;"/>
</svg>
`;
const sigInner = `
<div style="display:flex;justify-content:space-between;align-items:center;">
<div>
<div style="font-size:11px;color:#8b949e;letter-spacing:0.04em;">${headerLine}</div>
<div id="totp-code" style="font-family:monospace;font-size:28px;letter-spacing:0.12em;color:#c9d1d9;margin-top:4px;">${isSteam ? '·····' : '······'}</div>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">
${ringSvg}
<span id="totp-countdown" style="font-size:10px;color:#8b949e;font-variant-numeric:tabular-nums;">…</span>
</div>
</div>
`;
app.innerHTML = `
<div class="pad">
<div style="margin-bottom:12px;">
<div class="detail-title" style="margin-bottom:8px;">${escapeHtml(item.title)}</div>
${renderSignatureBlock({ accent: 'blue', children: sigInner })}
</div>
${c.issuer ? renderRow({ label: 'issuer', value: c.issuer }) : ''}
${c.label ? renderRow({ label: 'label', value: c.label }) : ''}
${renderRow({ label: 'kind', value: isSteam ? 'Steam Guard' : 'TOTP' })}
${renderConcealedRow({ id: 'totp-secret', label: 'secret', value: secretB32, monospace: true })}
<div class="form-actions" style="margin-top:14px;">
<button class="btn" id="back-btn">back</button>
<button class="btn" id="edit-btn">edit</button>
<button class="btn danger" id="trash-btn">trash</button>
</div>
</div>
`;
wireFieldHandlers(app);
// Start the ticker — re-fetches code + countdown every second from the SW.
stopTotpTicker();
const tick = async () => {
const r = await sendMessage({ type: 'get_totp', id: item.id });
if (!r.ok) return;
const { code, expires_at } = r.data as { code: string; expires_at: number };
const codeEl = document.getElementById('totp-code');
const cdEl = document.getElementById('totp-countdown');
const ring = document.getElementById('totp-ring-arc') as SVGCircleElement | null;
if (codeEl) codeEl.textContent = code;
const remaining = Math.max(0, expires_at - Math.floor(Date.now() / 1000));
const period = c.config.period_seconds || 30;
if (cdEl) cdEl.textContent = `${remaining}s`;
if (ring) {
const circumference = 2 * Math.PI * 14;
const offset = circumference * (1 - remaining / period);
ring.style.strokeDashoffset = String(offset);
}
};
void tick();
totpTickerId = setInterval(() => void tick(), 1000);
document.getElementById('back-btn')?.addEventListener('click', () => {
stopTotpTicker();
navigate('list');
});
document.getElementById('edit-btn')?.addEventListener('click', () => {
stopTotpTicker();
navigate('edit');
});
document.getElementById('trash-btn')?.addEventListener('click', async () => {
if (!confirm(`Move "${item.title}" to trash?`)) return;
stopTotpTicker();
const resp = await sendMessage({ type: 'delete_item', id: item.id });
if (!resp.ok) { setState({ error: resp.error }); return; }
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
});
}
let formKind: TotpKind = 'totp';
export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Item | null): void {
const state = getState();
const title = existing?.title ?? '';
const c = (existing?.core.type === 'totp') ? existing.core : null;
formKind = c?.config.kind === 'steam' ? 'steam' : 'totp';
const secretB32 = c?.config.secret ? base32Encode(new Uint8Array(c.config.secret)) : '';
const renderInner = () => `
<div class="pad">
<div class="detail-title" style="margin-bottom:16px;">${mode === 'add' ? 'new totp' : 'edit totp'}</div>
${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}
<div class="form-group"><label class="label" for="f-title">title *</label>
<input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>
<div class="form-group"><label class="label">kind</label>
<div class="inline-row">
<button type="button" id="kind-totp" class="btn ${formKind === 'totp' ? 'btn-primary' : ''}" style="flex:1;">TOTP</button>
<button type="button" id="kind-steam" class="btn ${formKind === 'steam' ? 'btn-primary' : ''}" style="flex:1;">Steam Guard</button>
</div>
<p class="muted" style="font-size:11px;margin-top:4px;" id="kind-blurb">${formKind === 'steam' ? 'Steam Mobile Authenticator (5-char alphanumeric)' : 'Standard time-based codes (6 digits)'}</p>
</div>
<div class="form-group"><label class="label" for="f-secret">secret (base32) *</label>
<input id="f-secret" type="text" value="${escapeHtml(secretB32)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<div class="form-group"><label class="label" for="f-issuer">issuer</label>
<input id="f-issuer" type="text" value="${escapeHtml(c?.issuer ?? '')}" placeholder="GitHub"></div>
<div class="form-group"><label class="label" for="f-label">label</label>
<input id="f-label" type="text" value="${escapeHtml(c?.label ?? '')}" placeholder="alice@github.com"></div>
<div class="form-actions">
<button class="btn" id="cancel-btn">cancel</button>
<button class="btn btn-primary" id="save-btn">save</button>
</div>
</div>
`;
app.innerHTML = renderInner();
const wireKindToggle = (): void => {
document.getElementById('kind-totp')?.addEventListener('click', () => {
formKind = 'totp';
// Re-render in place so the highlighted button + blurb update.
const titleVal = (document.getElementById('f-title') as HTMLInputElement).value;
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
const labelVal = (document.getElementById('f-label') as HTMLInputElement).value;
app.innerHTML = renderInner();
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
wireKindToggle();
wireFormButtons(mode, existing);
});
document.getElementById('kind-steam')?.addEventListener('click', () => {
formKind = 'steam';
const titleVal = (document.getElementById('f-title') as HTMLInputElement).value;
const secretVal = (document.getElementById('f-secret') as HTMLInputElement).value;
const issuerVal = (document.getElementById('f-issuer') as HTMLInputElement).value;
const labelVal = (document.getElementById('f-label') as HTMLInputElement).value;
app.innerHTML = renderInner();
(document.getElementById('f-title') as HTMLInputElement).value = titleVal;
(document.getElementById('f-secret') as HTMLInputElement).value = secretVal;
(document.getElementById('f-issuer') as HTMLInputElement).value = issuerVal;
(document.getElementById('f-label') as HTMLInputElement).value = labelVal;
wireKindToggle();
wireFormButtons(mode, existing);
});
};
wireKindToggle();
wireFormButtons(mode, existing);
(document.getElementById('f-title') as HTMLInputElement | null)?.focus();
}
function wireFormButtons(mode: 'add' | 'edit', existing: Item | null): void {
document.getElementById('cancel-btn')?.addEventListener('click', () => {
setState({ error: null });
navigate(mode === 'edit' ? 'detail' : 'list');
});
document.getElementById('save-btn')?.addEventListener('click', async () => {
await saveTotp(mode, existing);
});
}
async function saveTotp(mode: 'add' | 'edit', existing: Item | null): Promise<void> {
const state = getState();
const title = (document.getElementById('f-title') as HTMLInputElement).value.trim();
if (!title) { setState({ error: 'Title is required' }); return; }
const secretStr = (document.getElementById('f-secret') as HTMLInputElement).value.trim();
if (!secretStr) { setState({ error: 'Secret is required' }); return; }
let secretBytes: Uint8Array;
try {
secretBytes = base32Decode(secretStr);
} catch (err) {
setState({ error: `Invalid TOTP secret: ${err instanceof Error ? err.message : String(err)}` });
return;
}
if (secretBytes.length === 0) { setState({ error: 'Secret decoded to zero bytes' }); return; }
const get = (id: string) => (document.getElementById(id) as HTMLInputElement).value.trim();
const isSteam = formKind === 'steam';
const core = {
type: 'totp' as const,
config: {
secret: Array.from(secretBytes),
algorithm: 'sha1' as const,
digits: isSteam ? 5 : 6,
period_seconds: 30,
kind: (isSteam ? 'steam' : 'totp') as TotpKind,
},
issuer: get('f-issuer') || undefined,
label: get('f-label') || undefined,
};
const now = Math.floor(Date.now() / 1000);
const item: Item = {
id: existing?.id ?? '',
title, type: 'totp',
tags: existing?.tags ?? [],
favorite: existing?.favorite ?? false,
group: existing?.group, notes: existing?.notes,
created: existing?.created ?? now,
modified: now, trashed_at: undefined,
core,
sections: existing?.sections ?? [],
attachments: existing?.attachments ?? [],
field_history: existing?.field_history ?? {},
};
setState({ loading: true, error: null });
const resp = mode === 'add'
? await sendMessage({ type: 'add_item', item })
: await sendMessage({ type: 'update_item', id: state.selectedId!, item });
if (resp.ok) {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
navigate('list', { entries: data.items, selectedId: null, selectedItem: null });
} else navigate('list');
} else {
setState({ loading: false, error: resp.error });
}
}
```
- [ ] **Step 4: Wire Totp into the dispatchers**
In `item-detail.ts`: add `import * as totp from './types/totp';` and replace `case 'totp':` with `return totp.renderDetail(app, item);`.
In `item-form.ts`: same — replace `case 'totp':` with `return totp.renderForm(app, mode, existing);`.
- [ ] **Step 5: Run tests + build**
```bash
cd extension && bun run test 2>&1 | tail -5
cd extension && bun run build 2>&1 | tail -3
```
Expected: 79 tests pass; build clean.
- [ ] **Step 6: Commit**
```bash
git add extension/src/popup/components/types/totp.ts \
extension/src/popup/components/types/__tests__/totp.save.test.ts \
extension/src/popup/components/item-detail.ts \
extension/src/popup/components/item-form.ts
git commit -m "feat(ext/popup): Totp view + form (countdown ring, Steam toggle)"
```
---
## Slice 6 — "New…" picker + acceptance
### Task 9: New-item type picker on the toolbar
**Files:**
- Modify: `extension/src/popup/components/item-list.ts`
- [ ] **Step 1: Find the existing "+ New" button handler**
Read `extension/src/popup/components/item-list.ts`. The toolbar handler for `+ New` likely calls `navigate('add')` directly, defaulting to `state.newType ?? 'login'`.
- [ ] **Step 2: Replace the direct navigate with a popover**
Locate the `+ New` button's click handler in the toolbar and replace with:
```ts
document.getElementById('new-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
showNewTypePicker(e.currentTarget as HTMLElement);
});
```
Then add this helper at the bottom of `item-list.ts`:
```ts
const NEW_TYPE_OPTIONS: Array<{ type: import('../../shared/types').ItemType; icon: string; label: string; disabled?: boolean; tooltip?: string }> = [
{ type: 'login', icon: '🔑', label: 'login' },
{ type: 'secure_note', icon: '📝', label: 'secure note' },
{ type: 'identity', icon: '🪪', label: 'identity' },
{ type: 'card', icon: '💳', label: 'card' },
{ type: 'key', icon: '🗝', label: 'key' },
{ type: 'totp', icon: '⏱', label: 'totp' },
{ type: 'document', icon: '📄', label: 'document', disabled: true, tooltip: 'coming in γ — needs attachment upload' },
];
function showNewTypePicker(anchor: HTMLElement): void {
// Remove any existing picker.
document.querySelectorAll('.new-type-picker').forEach((el) => el.remove());
const picker = document.createElement('div');
picker.className = 'new-type-picker';
Object.assign(picker.style, {
position: 'absolute',
background: '#161b22',
border: '1px solid #30363d',
borderRadius: '6px',
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
padding: '4px',
minWidth: '160px',
zIndex: '999999',
fontSize: '12px',
});
const rect = anchor.getBoundingClientRect();
picker.style.top = `${rect.bottom + 4}px`;
picker.style.left = `${rect.left}px`;
for (const opt of NEW_TYPE_OPTIONS) {
const row = document.createElement('div');
Object.assign(row.style, {
padding: '6px 10px',
cursor: opt.disabled ? 'not-allowed' : 'pointer',
color: opt.disabled ? '#484f58' : '#c9d1d9',
borderRadius: '4px',
display: 'flex', alignItems: 'center', gap: '8px',
});
if (opt.tooltip) row.title = opt.tooltip;
row.innerHTML = `<span style="font-size:14px;width:16px;display:inline-block;text-align:center;">${opt.icon}</span><span>${opt.label}</span>`;
if (!opt.disabled) {
row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; });
row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; });
row.addEventListener('click', (ev) => {
ev.stopPropagation();
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
setState({ newType: opt.type });
navigate('add');
});
}
picker.appendChild(row);
}
document.body.appendChild(picker);
const closeOnOutside = (ev: MouseEvent) => {
if (!picker.contains(ev.target as Node)) {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
const closeOnEsc = (ev: KeyboardEvent) => {
if (ev.key === 'Escape') {
picker.remove();
document.removeEventListener('click', closeOnOutside);
document.removeEventListener('keydown', closeOnEsc);
}
};
setTimeout(() => {
document.addEventListener('click', closeOnOutside);
document.addEventListener('keydown', closeOnEsc);
}, 0);
}
```
Make sure `setState` and `navigate` are imported at the top — they already are (used by other handlers).
- [ ] **Step 3: Verify build**
Run: `cd extension && bun run build 2>&1 | tail -3`
Expected: clean.
- [ ] **Step 4: Commit**
```bash
git add extension/src/popup/components/item-list.ts
git commit -m "feat(ext/popup): + New picker with all 7 item types (Document disabled)"
```
### Task 10: Final acceptance — manual matrix + lint greps + tag
**Files:** none (verification + tag).
- [ ] **Step 1: Rust workspace + WASM target green**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1
cargo test --workspace 2>&1 | grep "test result"
cargo build -p relicario-wasm --target wasm32-unknown-unknown 2>&1 | tail -3
```
Expected: every line ends `0 failed`; WASM build clean.
- [ ] **Step 2: Extension build + tests green**
```bash
cd extension
bun run test 2>&1 | tail -5
bun run build:all 2>&1 | grep -E "warning|error|compiled" | tail -5
```
Expected: ~79 tests pass; both Chrome and Firefox compile with 2 warnings each.
- [ ] **Step 3: Lint greps**
```bash
cd /home/alee/Sources/relicario/.worktrees/typed-items-1c-beta1
git grep -n 'coming-soon\|Coming in' extension/src/popup/components/ | grep -v document && echo "FAIL: non-document coming-soon hit" || echo "PASS: only document is coming-soon"
git grep -n '@ts-nocheck' extension/src/ && echo "FAIL: @ts-nocheck remains" || echo "PASS: no @ts-nocheck"
git grep -n 'idfoto' extension/ && echo "FAIL: idfoto refs" || echo "PASS: no idfoto refs"
```
Expected: all three say `PASS`.
- [ ] **Step 4: Manual matrix on Chrome and Firefox**
Build then load:
```bash
cd extension && bun run build:all
```
Chrome: `chrome://extensions` → reload the relicario card; or load unpacked from `extension/dist`.
Firefox: `about:debugging#/runtime/this-firefox` → reload or re-load `extension/dist-firefox/manifest.json`.
For each of the 5 new types (SecureNote, Identity, Card, Key, Totp):
1. Click the toolbar `+ New` → picker appears with all 7 types; Document is greyed.
2. Pick the type → form opens with the right title, fields visible, save disabled if required field empty.
3. Fill required fields, save → list shows the new item with the right type-icon.
4. Open the item → detail view renders correctly (signature block + rows; reveal works on concealed fields; copy works via clipboard).
5. Edit → save → detail reflects changes; list "modified" updates.
6. Trash → row disappears from list; `relicario list --trashed` (CLI) shows it.
For Totp specifically:
- Default kind is TOTP; produces a 6-digit code that ticks every second.
- Switch to Steam Guard via the kind toggle, save → detail shows a 5-char alphanumeric code (`A`/`Z`/`0`/`O`/`1`/`I`/`L`/`S`/`5` never appear).
- Edit a TOTP item, switch its kind to Steam, save → detail re-fetches with the new kind (the `get_totp` SW handler picks up the saved kind).
- [ ] **Step 5: Tag the branch tip**
After the matrix passes:
```bash
git tag plan-1c-beta1-complete
```
Confirm with `git log --oneline -1` that the tag points at the final commit.
---
## Self-review
### Spec coverage check
- 5 typed-item forms (SecureNote, Identity, Card, Key, Totp): Tasks 4-8.
- Totp + Steam: Task 8 (covers both kinds via toggle); Steam encoding in core: Task 1.
- Concealed-with-reveal+copy: Task 2 (`renderConcealedRow`), used by all 5 types where applicable.
- Shared `fields.ts` helpers: Task 2 + Task 3 refactors Login onto them.
- Item-detail / item-form dispatcher updates: each per-type task includes the dispatcher edit.
- "New…" picker: Task 9.
- Per-type Vitest save-shape tests: each per-type task includes its test.
- Helper unit tests: Task 2.
- Document remains coming-soon: dispatchers in Task 3 keep the coming-soon branch only for `'document'`.
### Placeholder scan
No `TBD` / `TODO` / `implement later` / "similar to task N". Every code-bearing step has the actual code.
### Type consistency
- `renderRow` / `renderConcealedRow` / `renderSignatureBlock` signatures defined in Task 2 are used identically in Tasks 3-8.
- `wireFieldHandlers(scope)` consistent across all consumers.
- `state.newType` declared in Task 3 (Step 5), set in Task 9 (Step 2), read in `item-form.ts` dispatcher in Task 3 (Step 4).
- `formKind` module-level state in Task 8's totp.ts is initialized in `renderForm` and read in `saveTotp` — consistent within the file.
- `TotpKind` from `shared/types.ts` matches what α already exports (string literal `'totp' | 'steam' | { hotp: { counter: number } }`). Steam kind serializes as `"steam"` per the Rust `#[serde(rename_all = "snake_case")]` on `TotpKind` — consistent with the test mocks.