# 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.
