From 79b10d6a1823315b05cfcddc39cab48e99b411cf Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 1 May 2026 16:38:34 -0400 Subject: [PATCH] =?UTF-8?q?docs(plans):=20fullscreen=20UX=20Phase=202A=20?= =?UTF-8?q?=E2=80=94=20smart=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 tasks across 8 phases covering all 8 form-level smart-input affordances from spec section C (popup + fullscreen share login.ts) plus CLI parity (rate, --totp-qr, completions + groups.cache). Cross-plan coordination notes flag overlap with Phases 2B (recovery-QR) and 2C (password coloring) — no conflicts, only shared APIs (rate_passphrase, strength widget). Co-Authored-By: Claude Opus 4.7 --- ...-01-fullscreen-ux-phase-2a-smart-inputs.md | 2461 +++++++++++++++++ 1 file changed, 2461 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md diff --git a/docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md b/docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md new file mode 100644 index 0000000..b1a3aff --- /dev/null +++ b/docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md @@ -0,0 +1,2461 @@ +# Fullscreen UX Phase 2A — Smart Inputs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended per `feedback_subagent_default`) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire the 8 form-level smart-input affordances from spec section "C. Smart inputs" into the shared login form (popup + fullscreen tab use the same `popup/components/types/login.ts` via `popup/components/item-form.ts`), plus CLI parity for the three affordances that have a CLI counterpart (`rate `, `--totp-qr `, shell completion with dynamic `--group` enumeration). + +**Architecture:** A new `extension/src/shared/form-affordances/` directory holds focused mixin modules — one per affordance family (`url`, `group`, `password`, `totp`, `notes`). Each module exports a `wireXxx(form: HTMLElement, opts)` function that the form orchestrator calls during `renderForm()` after the HTML has been mounted. Three new popup-callable SW message types (`get_active_tab_url`, `list_groups`, `preview_totp_from_secret`) provide data the affordances need. `jsqr` is lazy-loaded only when the QR panel opens. CLI gets a new `relicario rate ` subcommand, a `--totp-qr ` flag on `add login` / `edit` (decoded via `rqrr`), and a `relicario completions ` subcommand whose generated script reads a plaintext `groups.cache` file the CLI refreshes on every manifest read. + +**Tech Stack:** TypeScript + vitest/happy-dom for extension; Rust + clap_complete + rqrr for CLI; `jsqr` (npm) for browser QR decode; existing `zxcvbn` (already a `relicario-core` dep) for strength. + +**Cross-plan coordination notes:** +- **Phase 2B (recovery-QR + entropy floor):** Phase 2B Task 11 (soft warning at unlock for grandfathered weak passphrases) and this plan's `relicario rate` subcommand both call `relicario_core::generators::rate_passphrase()`. No conflict — shared API. If 2B lands first, the soft-warning UI can reuse the same `wirePasswordStrength` widget introduced here for consistency. +- **Phase 2C (password coloring):** 2C paints colored spans in *detail-view* reveal surfaces and the generator-panel preview. This plan's C4 (password reveal toggle) flips a form `` to `type="text"` — coloring does not apply (you can't color text inside an input element). No overlap. +- **Phase 1 (visual foundation):** Already merged. This plan assumes `shared/glyphs.ts`, `--accent` / `--focus-ring` / `--accent-soft` / `--border-subtle` tokens, and `.req-pill` styling are in place. Spec value `--accent: #d49b3a` was a draft; the merged code uses `#d2ab43` — match the codebase, not the spec. + +--- + +## File Structure + +### Created — extension + +- `extension/src/shared/form-affordances/url-tools.ts` — `wireFillFromTab`, `wireHostnameChip` +- `extension/src/shared/form-affordances/group-autocomplete.ts` — `wireGroupAutocomplete` +- `extension/src/shared/form-affordances/password-tools.ts` — `wirePasswordReveal`, `wirePasswordStrength` +- `extension/src/shared/form-affordances/totp-tools.ts` — `wireTotpPreview`, `wireTotpQr` +- `extension/src/shared/form-affordances/notes-tools.ts` — `wireNotesMonoToggle` +- `extension/src/shared/form-affordances/__tests__/url-tools.test.ts` +- `extension/src/shared/form-affordances/__tests__/group-autocomplete.test.ts` +- `extension/src/shared/form-affordances/__tests__/password-tools.test.ts` +- `extension/src/shared/form-affordances/__tests__/totp-tools.test.ts` +- `extension/src/shared/form-affordances/__tests__/notes-tools.test.ts` + +### Modified — extension + +- `extension/src/shared/messages.ts` — add `get_active_tab_url`, `list_groups`, `preview_totp_from_secret` to `PopupMessage` union +- `extension/src/service-worker/router/popup-only.ts` — add three new handler arms +- `extension/src/service-worker/router/__tests__/router.test.ts` — three new tests +- `extension/src/popup/components/types/login.ts` — call all six wire functions in `renderForm()` +- `extension/src/popup/styles.css` — add `.fillable-input`, `.hostname-chip`, `.strength-bar`, `.strength-segment`, `.totp-preview`, `.totp-qr-panel`, `.notes-with-toggle` rules +- `extension/src/vault/vault.css` — same additions (popup + vault stylesheets already track each other) +- `extension/package.json` — add `jsqr ^1.4.0` dep + +### Created — CLI + +- `crates/relicario-cli/tests/smart_inputs.rs` — integration tests for `rate`, `--totp-qr`, `completions`, `groups.cache` + +### Modified — CLI + +- `crates/relicario-cli/Cargo.toml` — add `clap_complete = "4"`, `rqrr = "0.7"`, promote `image` from dev to runtime +- `crates/relicario-cli/src/main.rs` — add `Rate` and `Completions` subcommands, `--totp-qr` flag on `AddKind::Login` and `cmd_edit`, refresh `groups.cache` after every manifest read +- `crates/relicario-cli/src/helpers.rs` — add `groups_cache_path()` and `write_groups_cache()` helpers +- `crates/relicario-core/src/lib.rs` — re-export `rate_passphrase`, `StrengthEstimate` (already pub but make sure CLI can `use relicario_core::rate_passphrase`) + +--- + +## Phase A — Affordance scaffolding + +### Task 1: Create `shared/form-affordances/` skeleton + sanity test + +**Files:** +- Create: `extension/src/shared/form-affordances/index.ts` +- Test: `extension/src/shared/form-affordances/__tests__/index.test.ts` + +This task seeds the directory and proves the test infra picks it up, so subsequent tasks can write tests without ceremony. + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/index.test.ts +import { describe, it, expect } from 'vitest'; +import * as affordances from '../index'; + +describe('form-affordances barrel', () => { + it('exports nothing yet but the module loads', () => { + expect(typeof affordances).toBe('object'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/index.test.ts` +Expected: FAIL — "Cannot find module '../index'". + +- [ ] **Step 3: Create empty barrel** + +```typescript +// extension/src/shared/form-affordances/index.ts + +/// Shared form affordance modules. Each named export wires one family of +/// smart-input behavior (url, group, password, totp, notes) into a mounted +/// form element. Wired by `popup/components/types/login.ts` after the form +/// HTML is rendered. +export {}; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/index.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/form-affordances/ +git commit -m "ext(affordances): seed shared/form-affordances/ + barrel test" +``` + +--- + +## Phase B — URL affordances (C1, C2) + +### Task 2: SW handler `get_active_tab_url` + +**Files:** +- Modify: `extension/src/shared/messages.ts:16-72` (PopupMessage union) +- Modify: `extension/src/service-worker/router/popup-only.ts:35-50` (switch arm) +- Test: `extension/src/service-worker/router/__tests__/router.test.ts` + +The handler queries `chrome.tabs.query({active:true, lastFocusedWindow:true})`, returns `{ url, title }`, and filters out `chrome://` and extension URLs (returns `null` instead so the affordance can disable its button). + +- [ ] **Step 1: Write the failing router test** + +```typescript +// in router.test.ts — add to the existing describe block for popup-only handlers +it('get_active_tab_url returns active tab url + title', async () => { + // happy-dom does not provide chrome.tabs; stub it. + (globalThis as any).chrome = { + ...((globalThis as any).chrome ?? {}), + tabs: { + query: (q: any, cb: (tabs: any[]) => void) => { + cb([{ url: 'https://github.com/login', title: 'Sign in to GitHub' }]); + }, + }, + }; + const resp = await handle({ type: 'get_active_tab_url' } as any, makeState(), makeSender()); + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ url: 'https://github.com/login', title: 'Sign in to GitHub' }); +}); + +it('get_active_tab_url returns null for chrome:// pages', async () => { + (globalThis as any).chrome = { + ...((globalThis as any).chrome ?? {}), + tabs: { + query: (q: any, cb: (tabs: any[]) => void) => { + cb([{ url: 'chrome://newtab/', title: 'New Tab' }]); + }, + }, + }; + const resp = await handle({ type: 'get_active_tab_url' } as any, makeState(), makeSender()); + expect(resp.ok).toBe(true); + expect(resp.data).toBeNull(); +}); +``` + +If `makeState()` / `makeSender()` helpers don't exist yet in `router.test.ts`, look at the file's existing tests (e.g. `it('rate_passphrase ...')`) and copy their setup pattern verbatim. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t get_active_tab_url` +Expected: FAIL — "no matching message handler". + +- [ ] **Step 3: Add the message type** + +In `extension/src/shared/messages.ts`, add to the `PopupMessage` union: + +```typescript + | { type: 'get_active_tab_url' } +``` + +- [ ] **Step 4: Implement the handler** + +In `extension/src/service-worker/router/popup-only.ts`, add a new arm to the switch in `handle()`: + +```typescript + case 'get_active_tab_url': { + const tabs = await new Promise((resolve) => { + chrome.tabs.query({ active: true, lastFocusedWindow: true }, (t) => resolve(t)); + }); + const tab = tabs[0]; + if (!tab?.url) return { ok: true, data: null }; + // Filter out chrome:// and extension URLs — autofill doesn't apply. + if (/^(chrome|chrome-extension|moz-extension|edge|about|file):/i.test(tab.url)) { + return { ok: true, data: null }; + } + return { ok: true, data: { url: tab.url, title: tab.title ?? '' } }; + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t get_active_tab_url` +Expected: PASS (both cases). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/messages.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "ext(sw): add get_active_tab_url popup handler" +``` + +--- + +### Task 3: `wireFillFromTab` affordance (C1) + +**Files:** +- Create: `extension/src/shared/form-affordances/url-tools.ts` +- Test: `extension/src/shared/form-affordances/__tests__/url-tools.test.ts` +- Modify: `extension/src/popup/styles.css` (add `.fillable-input`) +- Modify: `extension/src/vault/vault.css` (mirror) + +The `⤓` glyph button sits in a flex row next to the URL input. On click it calls `get_active_tab_url`; on success it sets the URL field and (if title field is empty) the title field. If the SW returns `null`, the button stays disabled with title="no active tab". + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/url-tools.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { wireFillFromTab } from '../url-tools'; + +describe('wireFillFromTab', () => { + let form: HTMLElement; + let sendMessage: ReturnType; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + +
+ + +
+ `; + document.body.appendChild(form); + sendMessage = vi.fn(); + }); + + it('fills url + title from active tab on click', async () => { + sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } }); + wireFillFromTab(form, { sendMessage }); + (form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click(); + await Promise.resolve(); await Promise.resolve(); + expect((form.querySelector('#f-url') as HTMLInputElement).value).toBe('https://github.com/login'); + expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('GitHub'); + }); + + it('does not overwrite a non-empty title', async () => { + (form.querySelector('#f-title') as HTMLInputElement).value = 'My GitHub'; + sendMessage.mockResolvedValue({ ok: true, data: { url: 'https://github.com/login', title: 'GitHub' } }); + wireFillFromTab(form, { sendMessage }); + (form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click(); + await Promise.resolve(); await Promise.resolve(); + expect((form.querySelector('#f-title') as HTMLInputElement).value).toBe('My GitHub'); + }); + + it('disables the button if SW returns null', async () => { + sendMessage.mockResolvedValue({ ok: true, data: null }); + wireFillFromTab(form, { sendMessage }); + (form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).click(); + await Promise.resolve(); await Promise.resolve(); + expect((form.querySelector('#fill-from-tab-btn') as HTMLButtonElement).disabled).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/url-tools.test.ts -t wireFillFromTab` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the affordance** + +```typescript +// extension/src/shared/form-affordances/url-tools.ts +import { GLYPH_FILL_FROM_TAB } from '../glyphs'; + +export interface FillFromTabOpts { + sendMessage: (msg: { type: 'get_active_tab_url' }) => Promise<{ ok: boolean; data?: { url: string; title: string } | null }>; +} + +export function wireFillFromTab(form: HTMLElement, opts: FillFromTabOpts): void { + const btn = form.querySelector('#fill-from-tab-btn'); + if (!btn) return; + btn.addEventListener('click', async () => { + const resp = await opts.sendMessage({ type: 'get_active_tab_url' }); + if (!resp.ok || !resp.data) { + btn.disabled = true; + btn.title = 'no active tab'; + return; + } + const urlEl = form.querySelector('#f-url'); + const titleEl = form.querySelector('#f-title'); + if (urlEl) urlEl.value = resp.data.url; + if (titleEl && !titleEl.value.trim()) titleEl.value = resp.data.title; + }); +} + +export const FILL_FROM_TAB_BTN_HTML = ``; +``` + +- [ ] **Step 4: Add the CSS rule** + +Append to `extension/src/popup/styles.css` AND `extension/src/vault/vault.css` (popup + vault stylesheets already mirror each other): + +```css +/* Glyph button used by smart-input affordances. Sits inline with an input. */ +.glyph-btn { + min-width: 28px; + height: 28px; + padding: 0 6px; + background: var(--bg-input); + border: 1px solid var(--border-subtle); + border-radius: 3px; + color: var(--text-muted); + font-family: inherit; + font-size: 14px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.glyph-btn:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent); +} +.glyph-btn:focus-visible { + outline: none; + box-shadow: var(--focus-ring); +} +.glyph-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/url-tools.test.ts -t wireFillFromTab` +Expected: PASS (3 tests). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/form-affordances/url-tools.ts extension/src/shared/form-affordances/__tests__/url-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wireFillFromTab + .glyph-btn CSS" +``` + +--- + +### Task 4: `wireHostnameChip` affordance (C2) + +**Files:** +- Modify: `extension/src/shared/form-affordances/url-tools.ts` +- Modify: `extension/src/shared/form-affordances/__tests__/url-tools.test.ts` +- Modify: `extension/src/popup/styles.css` (add `.hostname-chip`) +- Modify: `extension/src/vault/vault.css` (mirror) + +Below the URL input, render a small chip (first letter of hostname on a colored background) + the bare hostname. Updates on `input` event, debounced 200ms. No network. Returns nothing if the URL doesn't parse (chip hidden). + +The chip's background is a deterministic hash of the hostname → one of 8 muted hues (so `github.com` always gets the same color; visual recall, not security). + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/url-tools.test.ts — append to the existing file +import { wireHostnameChip } from '../url-tools'; + +describe('wireHostnameChip', () => { + let form: HTMLElement; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` +
+ + +
+ `; + document.body.appendChild(form); + vi.useFakeTimers(); + }); + + it('renders chip + hostname on valid URL after debounce', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = 'https://github.com/login'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + const row = form.querySelector('#hostname-chip-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.textContent).toContain('github.com'); + expect(row.querySelector('.hostname-chip')?.textContent).toBe('G'); + }); + + it('hides chip if URL is empty', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true); + }); + + it('hides chip if URL does not parse', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = '!!!not-a-url'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + expect((form.querySelector('#hostname-chip-row') as HTMLElement).hidden).toBe(true); + }); + + it('treats scheme-less host as https://', () => { + wireHostnameChip(form); + const input = form.querySelector('#f-url') as HTMLInputElement; + input.value = 'gitlab.com/users/sign_in'; + input.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(250); + const row = form.querySelector('#hostname-chip-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.textContent).toContain('gitlab.com'); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/url-tools.test.ts -t wireHostnameChip` +Expected: FAIL — `wireHostnameChip` not exported. + +- [ ] **Step 3: Implement** + +Append to `extension/src/shared/form-affordances/url-tools.ts`: + +```typescript +const CHIP_HUES = [ + '#5ea0c4', '#c47e5e', '#5ec47a', '#c45e9c', + '#a3c45e', '#7e5ec4', '#c4b75e', '#5ec4c4', +]; + +function hostnameHue(host: string): string { + let h = 0; + for (let i = 0; i < host.length; i++) h = (h * 31 + host.charCodeAt(i)) | 0; + return CHIP_HUES[Math.abs(h) % CHIP_HUES.length]; +} + +function tryParseHost(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + const candidate = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed) ? trimmed : `https://${trimmed}`; + try { + const u = new URL(candidate); + return u.host || null; + } catch { + return null; + } +} + +export function wireHostnameChip(form: HTMLElement): void { + const input = form.querySelector('#f-url'); + const row = form.querySelector('#hostname-chip-row'); + if (!input || !row) return; + let timer: ReturnType | null = null; + + const update = () => { + const host = tryParseHost(input.value); + if (!host) { + row.hidden = true; + row.innerHTML = ''; + return; + } + const initial = host[0]?.toUpperCase() ?? '?'; + const hue = hostnameHue(host); + row.hidden = false; + row.innerHTML = `${initial}${host}`; + }; + + input.addEventListener('input', () => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { timer = null; update(); }, 200); + }); + update(); // initial render for prefilled values +} +``` + +- [ ] **Step 4: Add CSS rules** + +Append to `extension/src/popup/styles.css` AND `extension/src/vault/vault.css`: + +```css +.hostname-chip-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 11px; + color: var(--text-muted); +} +.hostname-chip { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 3px; + font-size: 10px; + font-weight: 600; + color: #0c1118; +} +.hostname-text { + font-family: ui-monospace, monospace; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/url-tools.test.ts` +Expected: PASS (7 total in file). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/form-affordances/url-tools.ts extension/src/shared/form-affordances/__tests__/url-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wireHostnameChip with debounced URL parse" +``` + +--- + +## Phase C — Group autocomplete + CLI parity + +### Task 5: SW handler `list_groups` + +**Files:** +- Modify: `extension/src/shared/messages.ts` (PopupMessage union) +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Test: `extension/src/service-worker/router/__tests__/router.test.ts` + +Reads `state.manifest.items`, collects unique non-empty `group` values, returns sorted. + +- [ ] **Step 1: Write the failing test** + +```typescript +// in router.test.ts +it('list_groups returns deduplicated sorted groups from manifest', async () => { + const state = makeState(); + state.manifest = { + items: { + a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false }, + b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false }, + c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false }, + d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false }, // no group + }, + } as any; + const resp = await handle({ type: 'list_groups' } as any, state, makeSender()); + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ groups: ['personal', 'work'] }); +}); + +it('list_groups returns empty array when manifest is null', async () => { + const state = makeState(); + state.manifest = null; + const resp = await handle({ type: 'list_groups' } as any, state, makeSender()); + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ groups: [] }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t list_groups` +Expected: FAIL — handler missing. + +- [ ] **Step 3: Add the message type** + +Append to `PopupMessage` union in `extension/src/shared/messages.ts`: + +```typescript + | { type: 'list_groups' } +``` + +- [ ] **Step 4: Implement the handler** + +Add new arm in `popup-only.ts handle()`: + +```typescript + case 'list_groups': { + if (!state.manifest) return { ok: true, data: { groups: [] } }; + const set = new Set(); + for (const id in state.manifest.items) { + const g = state.manifest.items[id].group; + if (g) set.add(g); + } + return { ok: true, data: { groups: Array.from(set).sort() } }; + } +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t list_groups` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/messages.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "ext(sw): add list_groups popup handler" +``` + +--- + +### Task 6: `wireGroupAutocomplete` affordance (C3) + +**Files:** +- Create: `extension/src/shared/form-affordances/group-autocomplete.ts` +- Test: `extension/src/shared/form-affordances/__tests__/group-autocomplete.test.ts` + +Fetches the group list once on form open, builds a `` and sets `list="groups-datalist"` on the group input. Browser handles the dropdown UI. + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/group-autocomplete.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { wireGroupAutocomplete } from '../group-autocomplete'; + +describe('wireGroupAutocomplete', () => { + let form: HTMLElement; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ``; + document.body.appendChild(form); + }); + + it('attaches datalist with all groups', async () => { + const sendMessage = vi.fn().mockResolvedValue({ + ok: true, + data: { groups: ['personal', 'work', 'finance'] }, + }); + await wireGroupAutocomplete(form, { sendMessage }); + const list = document.getElementById('groups-datalist') as HTMLDataListElement | null; + expect(list).not.toBeNull(); + const opts = Array.from(list!.querySelectorAll('option')).map((o) => o.value); + expect(opts).toEqual(['personal', 'work', 'finance']); + const input = form.querySelector('#f-group') as HTMLInputElement; + expect(input.getAttribute('list')).toBe('groups-datalist'); + }); + + it('is a no-op if SW returns error', async () => { + const sendMessage = vi.fn().mockResolvedValue({ ok: false, error: 'vault_locked' }); + await wireGroupAutocomplete(form, { sendMessage }); + const input = form.querySelector('#f-group') as HTMLInputElement; + expect(input.getAttribute('list')).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/group-autocomplete.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +```typescript +// extension/src/shared/form-affordances/group-autocomplete.ts + +export interface GroupAutocompleteOpts { + sendMessage: (msg: { type: 'list_groups' }) => Promise<{ ok: boolean; data?: { groups: string[] }; error?: string }>; +} + +const DATALIST_ID = 'groups-datalist'; + +export async function wireGroupAutocomplete(form: HTMLElement, opts: GroupAutocompleteOpts): Promise { + const input = form.querySelector('#f-group'); + if (!input) return; + const resp = await opts.sendMessage({ type: 'list_groups' }); + if (!resp.ok || !resp.data) return; + + // Datalists must live in the document, not nested inside an input. Reuse if + // we've already mounted one this session. + let list = document.getElementById(DATALIST_ID) as HTMLDataListElement | null; + if (!list) { + list = document.createElement('datalist'); + list.id = DATALIST_ID; + document.body.appendChild(list); + } + list.innerHTML = resp.data.groups.map((g) => ``).join(''); + input.setAttribute('list', DATALIST_ID); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/group-autocomplete.test.ts` +Expected: PASS (2). + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/form-affordances/group-autocomplete.ts extension/src/shared/form-affordances/__tests__/group-autocomplete.test.ts +git commit -m "ext(affordances): wireGroupAutocomplete via " +``` + +--- + +### Task 7: CLI `relicario completions ` subcommand + +**Files:** +- Modify: `crates/relicario-cli/Cargo.toml` — add `clap_complete = "4"` +- Modify: `crates/relicario-cli/src/main.rs` — add `Completions` subcommand +- Test: `crates/relicario-cli/tests/smart_inputs.rs` (new file) + +Static-only first pass: emits `bash`/`zsh`/`fish` completion script for the binary's clap surface. Dynamic group enumeration ships in Task 8. + +- [ ] **Step 1: Write the failing integration test** + +```rust +// crates/relicario-cli/tests/smart_inputs.rs +use assert_cmd::Command; +use predicates::str::contains; + +#[test] +fn completions_bash_emits_script() { + Command::cargo_bin("relicario").unwrap() + .args(["completions", "bash"]) + .assert() + .success() + .stdout(contains("_relicario")) // bash-completion functions are prefixed with _ + .stdout(contains("complete -F")); +} + +#[test] +fn completions_zsh_emits_script() { + Command::cargo_bin("relicario").unwrap() + .args(["completions", "zsh"]) + .assert() + .success() + .stdout(contains("#compdef relicario")); +} + +#[test] +fn completions_fish_emits_script() { + Command::cargo_bin("relicario").unwrap() + .args(["completions", "fish"]) + .assert() + .success() + .stdout(contains("complete -c relicario")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p relicario-cli --test smart_inputs completions_` +Expected: FAIL — `completions` subcommand missing. + +- [ ] **Step 3: Add dep** + +In `crates/relicario-cli/Cargo.toml`, append to `[dependencies]`: + +```toml +clap_complete = "4" +``` + +- [ ] **Step 4: Add subcommand** + +In `crates/relicario-cli/src/main.rs`: + +Near the top with the other `use` statements: + +```rust +use clap_complete::{generate, Shell}; +``` + +In the `enum Command` body (around line 24), add: + +```rust + /// Emit a shell completion script for the given shell. + /// Pipe to your shell's completion file (e.g. `> /etc/bash_completion.d/relicario`). + Completions { + #[arg(value_enum)] + shell: Shell, + }, +``` + +In the `match command { ... }` dispatch in `main()` (look for the existing arms calling `cmd_init`, `cmd_add`, etc.), add: + +```rust + Command::Completions { shell } => { + let mut cmd = Cli::command(); + generate(shell, &mut cmd, "relicario", &mut std::io::stdout()); + Ok(()) + } +``` + +You'll need `use clap::CommandFactory;` near the top to get `Cli::command()`. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cargo test -p relicario-cli --test smart_inputs completions_` +Expected: PASS (3). + +- [ ] **Step 6: Commit** + +```bash +git add crates/relicario-cli/Cargo.toml crates/relicario-cli/src/main.rs crates/relicario-cli/tests/smart_inputs.rs Cargo.lock +git commit -m "cli: add 'completions ' subcommand via clap_complete" +``` + +--- + +### Task 8: Plaintext `groups.cache` for dynamic `--group ` enumeration + +**Files:** +- Modify: `crates/relicario-cli/src/helpers.rs` — add `groups_cache_path()`, `write_groups_cache()` +- Modify: `crates/relicario-cli/src/main.rs` — call `write_groups_cache()` after `cmd_list`, `cmd_add`, `cmd_edit`, `cmd_get`, `cmd_rm` (anywhere the manifest is read into memory) +- Modify: `crates/relicario-cli/tests/smart_inputs.rs` — add cache-refresh test + +**Design tradeoff:** The vault dir on disk is encrypted. Shell completion cannot prompt for a passphrase mid-tab-press. Therefore we maintain a plaintext file `/.relicario/groups.cache` (one group name per line) that the CLI refreshes after every successful manifest decrypt. The completion script reads this file directly. + +This is a **new plaintext leak surface** — group names become readable to any user/process with read access to the vault dir. Group names in this project are typically benign (`work`, `personal`, `finance`) and the `.git/` history already exposes commit messages with item titles. The leak is low-severity but should be called out: +- Add a sentence to the CLI README and `--help` for `completions` mentioning the cache. +- Document opt-out: if `RELICARIO_NO_GROUPS_CACHE=1` is set, skip the write (and completion will just not enumerate groups). + +- [ ] **Step 1: Write the failing test** + +Append to `crates/relicario-cli/tests/smart_inputs.rs`: + +```rust +use std::fs; + +#[test] +fn list_command_refreshes_groups_cache() { + use tempfile::TempDir; + + let tmp = TempDir::new().unwrap(); + let vault = tmp.path().join("vault"); + fs::create_dir_all(&vault).unwrap(); + + // Use the shared test-helper to init a vault. The pattern is from + // basic_flows.rs — copy whichever helper inits a vault with a known + // passphrase + ref image. Set $RELICARIO_VAULT to `vault` and run: + // relicario init + // relicario add login --title T --group work --password p + // relicario list + // Then assert /.relicario/groups.cache contains "work\n". + // + // (Helper lookup: see existing tests/basic_flows.rs for `init_test_vault()` + // or equivalent; reuse it rather than re-implementing.) + // + // Example (sketch — adapt to actual helper): + // + // let env = init_test_vault(&vault); + // relicario_cmd(&env).args(["add", "login", "--title", "T", "--group", "work", "--password-prompt"]) + // .write_stdin("password\npassword\n").assert().success(); + // relicario_cmd(&env).args(["list"]).assert().success(); + // let cache = fs::read_to_string(vault.join(".relicario/groups.cache")).unwrap(); + // assert!(cache.lines().any(|l| l == "work")); + + // If init_test_vault() is not present, this test is the trigger to add one + // (see tests/basic_flows.rs for the pattern). + let _ = vault; // placeholder: implementer must wire up the helper. +} + +#[test] +fn no_groups_cache_env_var_suppresses_write() { + // Same setup as above, but set RELICARIO_NO_GROUPS_CACHE=1 before `list`. + // Assert /.relicario/groups.cache does NOT exist. +} +``` + +(Implementation note: lift the existing init helper out of `tests/basic_flows.rs` into a new `tests/common/mod.rs` if it isn't already shared. If `basic_flows.rs` keeps it private, copy the minimal init sequence inline.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p relicario-cli --test smart_inputs list_command_refreshes_groups_cache` +Expected: FAIL — cache file not written. + +- [ ] **Step 3: Add cache helpers** + +In `crates/relicario-cli/src/helpers.rs`, append: + +```rust +use std::path::PathBuf; +use std::collections::BTreeSet; + +/// Path to the plaintext `groups.cache` file used by shell completion to +/// enumerate `--group ` candidates without unlocking the vault. +/// +/// **Plaintext leak:** group names land on disk in cleartext alongside the +/// vault directory. This is intentional — the file feeds shell completion, +/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1` +/// to suppress the write. +pub fn groups_cache_path(vault_dir: &std::path::Path) -> PathBuf { + vault_dir.join(".relicario").join("groups.cache") +} + +pub fn write_groups_cache(vault_dir: &std::path::Path, groups: &BTreeSet) -> std::io::Result<()> { + if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() { + return Ok(()); + } + let path = groups_cache_path(vault_dir); + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } + let mut body = String::new(); + for g in groups { + body.push_str(g); + body.push('\n'); + } + std::fs::write(path, body) +} +``` + +- [ ] **Step 4: Wire into manifest-reading commands** + +In `crates/relicario-cli/src/main.rs`, find every location where `vault::load_manifest(&unlocked, ...)` is called (or whatever the equivalent decrypt entry point is — match the existing pattern). After the manifest is in memory, collect groups and call `write_groups_cache`. Suggested helper at the top of `main.rs`: + +```rust +fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) { + let mut set = std::collections::BTreeSet::::new(); + for entry in manifest.items.values() { + if let Some(g) = entry.group.as_ref() { + if !g.is_empty() { set.insert(g.clone()); } + } + } + let _ = helpers::write_groups_cache(vault_dir, &set); +} +``` + +Call sites (search `main.rs` for `load_manifest`): +- `cmd_list` — after manifest load +- `cmd_add` — after manifest load + new-item save +- `cmd_edit` — after manifest load + edited-item save +- `cmd_rm` / `cmd_restore` / `cmd_purge` — after manifest load +- `cmd_get` — after manifest load (since `get` is the most common read) + +- [ ] **Step 5: Bake completion script awareness** + +The clap_complete-generated bash script for `--group` will use a default file/path completion. To override it for `--group`, post-process the script — but cleaner: **document** in the user-facing help that the completion script's `--group` placeholder reads `${RELICARIO_VAULT:-$HOME/.local/share/relicario}/.relicario/groups.cache`. Add this snippet to the `Completions` doc comment so users see it on `--help`: + +```rust + /// Emit a shell completion script for the given shell. + /// + /// For `--group ` autocomplete, the bash/zsh/fish scripts read + /// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file, + /// which the CLI refreshes on every manifest read. Set + /// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion + /// will fall back to no value enumeration). + /// + /// Pipe stdout to your shell's completion location (e.g. + /// `relicario completions bash > /etc/bash_completion.d/relicario`). + Completions { + #[arg(value_enum)] + shell: Shell, + }, +``` + +(A future enhancement could splice a custom `_relicario_groups()` function into the generated script that `cat`s the cache. That's out of scope for 2A — clap_complete's current dynamic-completion API is unstable and the cache approach already gets us 90%.) + +- [ ] **Step 6: Run test to verify it passes** + +Run: `cargo test -p relicario-cli --test smart_inputs` +Expected: PASS (5 tests now). + +- [ ] **Step 7: Commit** + +```bash +git add crates/relicario-cli/src/helpers.rs crates/relicario-cli/src/main.rs crates/relicario-cli/tests/smart_inputs.rs +git commit -m "cli: write groups.cache for shell-completion --group enumeration" +``` + +--- + +## Phase D — Password tools + CLI parity + +### Task 9: `wirePasswordReveal` affordance (C4) + +**Files:** +- Create: `extension/src/shared/form-affordances/password-tools.ts` +- Test: `extension/src/shared/form-affordances/__tests__/password-tools.test.ts` + +`⊙` (hidden) ↔ `⊘` (revealed) glyph button next to the password input. Click toggles `input.type`. Resets to `password` if the form is unmounted (call sites pass a teardown registry function). + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/password-tools.test.ts +import { describe, it, expect, beforeEach } from 'vitest'; +import { wirePasswordReveal } from '../password-tools'; + +describe('wirePasswordReveal', () => { + let form: HTMLElement; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + `; + document.body.appendChild(form); + }); + + it('flips input.type and glyph on click', () => { + wirePasswordReveal(form); + const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement; + const input = form.querySelector('#f-password') as HTMLInputElement; + expect(input.type).toBe('password'); + expect(btn.textContent).toBe('⊙'); + + btn.click(); + expect(input.type).toBe('text'); + expect(btn.textContent).toBe('⊘'); + expect(btn.title).toBe('hide'); + + btn.click(); + expect(input.type).toBe('password'); + expect(btn.textContent).toBe('⊙'); + expect(btn.title).toBe('reveal'); + }); + + it('teardown returned by wirePasswordReveal resets to password', () => { + const teardown = wirePasswordReveal(form); + const btn = form.querySelector('#reveal-password-btn') as HTMLButtonElement; + btn.click(); // now revealed + expect((form.querySelector('#f-password') as HTMLInputElement).type).toBe('text'); + teardown(); + expect((form.querySelector('#f-password') as HTMLInputElement).type).toBe('password'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/password-tools.test.ts -t wirePasswordReveal` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +```typescript +// extension/src/shared/form-affordances/password-tools.ts +import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs'; + +/// Returns a teardown fn the caller must invoke on unmount. +export function wirePasswordReveal(form: HTMLElement): () => void { + const btn = form.querySelector('#reveal-password-btn'); + const input = form.querySelector('#f-password'); + if (!btn || !input) return () => {}; + + const handler = () => { + if (input.type === 'password') { + input.type = 'text'; + btn.textContent = GLYPH_HIDE; + btn.title = 'hide'; + } else { + input.type = 'password'; + btn.textContent = GLYPH_REVEAL; + btn.title = 'reveal'; + } + }; + btn.addEventListener('click', handler); + + return () => { + btn.removeEventListener('click', handler); + input.type = 'password'; + btn.textContent = GLYPH_REVEAL; + btn.title = 'reveal'; + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/password-tools.test.ts -t wirePasswordReveal` +Expected: PASS (2). + +- [ ] **Step 5: Commit** + +```bash +git add extension/src/shared/form-affordances/password-tools.ts extension/src/shared/form-affordances/__tests__/password-tools.test.ts +git commit -m "ext(affordances): wirePasswordReveal toggle" +``` + +--- + +### Task 10: `wirePasswordStrength` affordance (C5) + +**Files:** +- Modify: `extension/src/shared/form-affordances/password-tools.ts` +- Modify: `extension/src/shared/form-affordances/__tests__/password-tools.test.ts` +- Modify: `extension/src/popup/styles.css` (add `.strength-bar`, `.strength-segment`, `.s-very-weak` … `.s-strong`) +- Modify: `extension/src/vault/vault.css` (mirror) + +5-segment bar below the password input + label "strength: weak / fair / good / strong · ~10ⁿ guesses". Reuses `scheduleRate` from `setup/setup-helpers.ts` so debounce is consistent with the setup wizard (150ms). Empty input → bar hidden. + +- [ ] **Step 1: Write the failing test** + +Append to `password-tools.test.ts`: + +```typescript +import { wirePasswordStrength } from '../password-tools'; + +describe('wirePasswordStrength', () => { + let form: HTMLElement; + let scheduleRate: ReturnType; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + `; + document.body.appendChild(form); + scheduleRate = vi.fn(); + }); + + it('shows bar with score class on input', () => { + scheduleRate.mockImplementation((_pw, cb) => cb({ score: 3, guessesLog10: 11.4 })); + wirePasswordStrength(form, { scheduleRate }); + const input = form.querySelector('#f-password') as HTMLInputElement; + input.value = 'CorrectHorseBatteryStaple'; + input.dispatchEvent(new Event('input')); + const row = form.querySelector('#strength-bar-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.querySelector('.strength-bar')?.className).toContain('s-good'); + expect(row.querySelector('.strength-label')?.textContent).toContain('good'); + expect(row.querySelector('.strength-label')?.textContent).toContain('10^11'); + }); + + it('hides bar when input is empty', () => { + scheduleRate.mockImplementation((_pw, cb) => cb({ score: -1, guessesLog10: -1 })); + wirePasswordStrength(form, { scheduleRate }); + const input = form.querySelector('#f-password') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + const row = form.querySelector('#strength-bar-row') as HTMLElement; + expect(row.hidden).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/password-tools.test.ts -t wirePasswordStrength` +Expected: FAIL — `wirePasswordStrength` not exported. + +- [ ] **Step 3: Implement** + +Append to `extension/src/shared/form-affordances/password-tools.ts`: + +```typescript +import { STRENGTH_LABELS, entropyText, type Strength } from '../../setup/setup-helpers'; + +export interface PasswordStrengthOpts { + scheduleRate: (passphrase: string, cb: (s: Strength) => void) => void; +} + +export function wirePasswordStrength(form: HTMLElement, opts: PasswordStrengthOpts): void { + const input = form.querySelector('#f-password'); + const row = form.querySelector('#strength-bar-row'); + if (!input || !row) return; + const bar = row.querySelector('.strength-bar'); + const label = row.querySelector('.strength-label'); + if (!bar || !label) return; + + const update = () => { + const v = input.value; + if (!v) { + row.hidden = true; + return; + } + opts.scheduleRate(v, (s) => { + if (s.score < 0) { row.hidden = true; return; } + row.hidden = false; + // Reset score classes, then add the current one to the bar element. + bar.className = 'strength-bar'; + const cls = STRENGTH_LABELS[s.score]?.cls ?? 's-very-weak'; + bar.classList.add(cls); + // Light up segments 0..score (5-segment bar). + Array.from(bar.children).forEach((seg, i) => { + (seg as HTMLElement).classList.toggle('lit', i <= s.score); + }); + const text = STRENGTH_LABELS[s.score]?.text ?? '?'; + label.textContent = `${text} · ${entropyText(s.guessesLog10)}`; + }); + }; + + input.addEventListener('input', update); + update(); +} +``` + +- [ ] **Step 4: Add CSS** + +Append to `extension/src/popup/styles.css` AND `extension/src/vault/vault.css`: + +```css +.strength-bar-row { + margin-top: 6px; + display: flex; + flex-direction: column; + gap: 4px; +} +.strength-bar { + display: flex; + gap: 3px; + height: 4px; +} +.strength-bar > span { + flex: 1; + background: var(--border-subtle); + border-radius: 2px; +} +.strength-bar.s-very-weak > span.lit { background: #c75a4f; } +.strength-bar.s-weak > span.lit { background: #c75a4f; } +.strength-bar.s-fair > span.lit { background: #d49b3a; } +.strength-bar.s-good > span.lit { background: #d49b3a; } +.strength-bar.s-strong > span.lit { background: #6cb37a; } +.strength-label { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/password-tools.test.ts` +Expected: PASS (4 tests in file). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/form-affordances/password-tools.ts extension/src/shared/form-affordances/__tests__/password-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wirePasswordStrength via scheduleRate" +``` + +--- + +### Task 11: CLI `relicario rate ` subcommand + +**Files:** +- Modify: `crates/relicario-cli/src/main.rs` — add `Rate` subcommand + `cmd_rate` +- Modify: `crates/relicario-cli/tests/smart_inputs.rs` — add `rate_*` tests + +Prints zxcvbn score + guess count + the friendly entropy phrase. Two input modes: +- Positional arg: `relicario rate "my passphrase"` (convenient but the passphrase ends up in shell history — warn in `--help`) +- Stdin: `relicario rate -` reads one line from stdin (no echo if isatty) + +Reuses `relicario_core::generators::rate_passphrase()`. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/smart_inputs.rs`: + +```rust +#[test] +fn rate_strong_passphrase_prints_score_and_guesses() { + Command::cargo_bin("relicario").unwrap() + .args(["rate", "correct horse battery staple table cocoa rocket spirit ferment"]) + .assert() + .success() + .stdout(contains("score:")) + .stdout(contains("guesses:")) + .stdout(contains("strong")); +} + +#[test] +fn rate_weak_passphrase_exits_zero_with_weak_label() { + // Note: `rate` is informational — it does NOT exit nonzero on weak input. + // The hard gate lives at `init` (Plan 2B Task 10). + Command::cargo_bin("relicario").unwrap() + .args(["rate", "password"]) + .assert() + .success() + .stdout(contains("very weak").or(contains("weak"))); +} + +#[test] +fn rate_reads_from_stdin_when_arg_is_dash() { + Command::cargo_bin("relicario").unwrap() + .args(["rate", "-"]) + .write_stdin("correcthorsebatterystaple\n") + .assert() + .success() + .stdout(contains("score:")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-cli --test smart_inputs rate_` +Expected: FAIL — `rate` subcommand missing. + +- [ ] **Step 3: Add subcommand** + +In `crates/relicario-cli/src/main.rs`: + +```rust + /// Rate a passphrase with zxcvbn — prints score (0-4) and estimated + /// guesses. Informational only; does not gate vault operations. + /// + /// Pass `-` as the argument to read one line from stdin instead, which + /// keeps the passphrase out of shell history. + Rate { + /// Passphrase to score, or `-` to read from stdin. + passphrase: String, + }, +``` + +Dispatch arm: + +```rust + Command::Rate { passphrase } => cmd_rate(passphrase), +``` + +Implementation: + +```rust +fn cmd_rate(passphrase: String) -> Result<()> { + let pw: String = if passphrase == "-" { + use std::io::BufRead; + let stdin = std::io::stdin(); + let mut line = String::new(); + stdin.lock().read_line(&mut line)?; + line.trim_end_matches(&['\r', '\n'][..]).to_string() + } else { + passphrase + }; + let est = relicario_core::generators::rate_passphrase(&pw); + let label = match est.score { + 0 => "very weak", + 1 => "weak", + 2 => "fair", + 3 => "good", + 4 => "strong", + _ => "?", + }; + println!("score: {}/4 ({})", est.score, label); + println!("guesses: ~10^{:.1}", est.guesses_log10); + println!("note: init requires score ≥ 3 (see `relicario init`)"); + Ok(()) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cargo test -p relicario-cli --test smart_inputs rate_` +Expected: PASS (3). + +- [ ] **Step 5: Commit** + +```bash +git add crates/relicario-cli/src/main.rs crates/relicario-cli/tests/smart_inputs.rs +git commit -m "cli: add 'rate ' subcommand (zxcvbn)" +``` + +--- + +## Phase E — TOTP tools + CLI parity + +### Task 12: SW handler `preview_totp_from_secret` + +**Files:** +- Modify: `extension/src/shared/messages.ts` +- Modify: `extension/src/service-worker/router/popup-only.ts` +- Modify: `extension/src/service-worker/router/__tests__/router.test.ts` + +Accepts `{ secret_b32: string }`, validates as base32 (length ≥ 16, charset `A-Z2-7=`), constructs a transient `TotpConfig`, calls `wasm.totp_compute`, returns `{ code, expires_at }`. Does **not** persist anything — exists so the form can preview a code from the unsaved value in the secret field without contaminating `get_totp`'s code path. + +- [ ] **Step 1: Write the failing test** + +In `router.test.ts`: + +```typescript +it('preview_totp_from_secret returns code for valid base32', async () => { + const state = makeState(); + state.wasm = { + totp_compute: vi.fn().mockReturnValue({ code: '123456', expires_at: 9_999_999_999 }), + }; + const resp = await handle( + { type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any, + state, makeSender(), + ); + expect(resp.ok).toBe(true); + expect(resp.data).toEqual({ code: '123456', expires_at: 9_999_999_999 }); + // Verify a transient TotpConfig was passed (sha1, 6 digits, 30s) + const cfgArg = JSON.parse(state.wasm.totp_compute.mock.calls[0][0]); + expect(cfgArg.algorithm).toBe('sha1'); + expect(cfgArg.digits).toBe(6); + expect(cfgArg.period_seconds).toBe(30); +}); + +it('preview_totp_from_secret rejects invalid base32', async () => { + const state = makeState(); + state.wasm = { totp_compute: vi.fn() }; + const resp = await handle( + { type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any, + state, makeSender(), + ); + expect(resp.ok).toBe(false); + expect(resp.error).toMatch(/invalid/i); + expect(state.wasm.totp_compute).not.toHaveBeenCalled(); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t preview_totp_from_secret` +Expected: FAIL. + +- [ ] **Step 3: Add message type** + +In `extension/src/shared/messages.ts`: + +```typescript + | { type: 'preview_totp_from_secret'; secret_b32: string } +``` + +- [ ] **Step 4: Implement handler** + +First, add a static import at the top of `popup-only.ts`, alongside the existing `import * as vault from '../vault';` block: + +```typescript +import { base32Decode } from '../../shared/base32'; +``` + +Then add to the `handle()` switch: + +```typescript + case 'preview_totp_from_secret': { + const cleaned = msg.secret_b32.toUpperCase().replace(/\s+/g, '').replace(/=+$/, ''); + if (cleaned.length < 16 || !/^[A-Z2-7]+$/.test(cleaned)) { + return { ok: false, error: 'invalid base32 secret' }; + } + let secretBytes: Uint8Array; + try { + secretBytes = base32Decode(cleaned); + } catch (e) { + return { ok: false, error: `invalid base32: ${e instanceof Error ? e.message : String(e)}` }; + } + const cfg = { + secret: Array.from(secretBytes), + algorithm: 'sha1', + digits: 6, + period_seconds: 30, + kind: 'totp', + }; + const now = Math.floor(Date.now() / 1000); + const result = state.wasm.totp_compute(JSON.stringify(cfg), BigInt(now)); + return { ok: true, data: { code: result.code, expires_at: result.expires_at } }; + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd extension && npx vitest run src/service-worker/router/__tests__/router.test.ts -t preview_totp_from_secret` +Expected: PASS (2). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/messages.ts extension/src/service-worker/router/popup-only.ts extension/src/service-worker/router/__tests__/router.test.ts +git commit -m "ext(sw): add preview_totp_from_secret popup handler" +``` + +--- + +### Task 13: `wireTotpPreview` affordance (C6) + +**Files:** +- Create: `extension/src/shared/form-affordances/totp-tools.ts` +- Test: `extension/src/shared/form-affordances/__tests__/totp-tools.test.ts` +- Modify: `extension/src/popup/styles.css` (add `.totp-preview`) +- Modify: `extension/src/vault/vault.css` (mirror) + +When `#f-totp` contains a valid base32 string (length ≥ 16, charset `A-Z2-7=`), render a dashed-border preview box below it: `492 837 · 23s`. Updates every 1s via interval. Returns a teardown fn the form must call on unmount. + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/totp-tools.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { wireTotpPreview } from '../totp-tools'; + +describe('wireTotpPreview', () => { + let form: HTMLElement; + let sendMessage: ReturnType; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + `; + document.body.appendChild(form); + sendMessage = vi.fn(); + vi.useFakeTimers(); + }); + + it('shows preview when secret is valid base32', async () => { + sendMessage.mockResolvedValue({ ok: true, data: { code: '492837', expires_at: Math.floor(Date.now() / 1000) + 23 } }); + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'JBSWY3DPEHPK3PXP'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const row = form.querySelector('#totp-preview-row') as HTMLElement; + expect(row.hidden).toBe(false); + expect(row.querySelector('.totp-code')?.textContent).toBe('492 837'); + expect(row.querySelector('.totp-countdown')?.textContent).toMatch(/\d+s/); + teardown(); + }); + + it('hides preview when secret is too short', async () => { + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'TOOSHORT'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const row = form.querySelector('#totp-preview-row') as HTMLElement; + expect(row.hidden).toBe(true); + expect(sendMessage).not.toHaveBeenCalled(); + teardown(); + }); + + it('teardown stops the interval', async () => { + sendMessage.mockResolvedValue({ ok: true, data: { code: '111111', expires_at: Math.floor(Date.now() / 1000) + 30 } }); + const teardown = wireTotpPreview(form, { sendMessage }); + const input = form.querySelector('#f-totp') as HTMLInputElement; + input.value = 'JBSWY3DPEHPK3PXP'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(50); + const callsBefore = sendMessage.mock.calls.length; + teardown(); + await vi.advanceTimersByTimeAsync(2000); + expect(sendMessage.mock.calls.length).toBe(callsBefore); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/totp-tools.test.ts -t wireTotpPreview` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +```typescript +// extension/src/shared/form-affordances/totp-tools.ts + +export interface TotpPreviewOpts { + sendMessage: (msg: { type: 'preview_totp_from_secret'; secret_b32: string }) => + Promise<{ ok: boolean; data?: { code: string; expires_at: number }; error?: string }>; +} + +const VALID_B32 = /^[A-Z2-7]{16,}=*$/; + +export function wireTotpPreview(form: HTMLElement, opts: TotpPreviewOpts): () => void { + const input = form.querySelector('#f-totp'); + const row = form.querySelector('#totp-preview-row'); + if (!input || !row) return () => {}; + const codeEl = row.querySelector('.totp-code'); + const cdEl = row.querySelector('.totp-countdown'); + if (!codeEl || !cdEl) return () => {}; + + let interval: ReturnType | null = null; + let lastSecret = ''; + + const tick = async () => { + const cleaned = lastSecret.toUpperCase().replace(/\s+/g, '').replace(/=+$/, ''); + if (!VALID_B32.test(cleaned)) { + row.hidden = true; + return; + } + const resp = await opts.sendMessage({ type: 'preview_totp_from_secret', secret_b32: cleaned }); + if (!resp.ok || !resp.data) { + row.hidden = true; + return; + } + row.hidden = false; + // Format "492837" → "492 837" for legibility. + codeEl.textContent = resp.data.code.length === 6 + ? `${resp.data.code.slice(0, 3)} ${resp.data.code.slice(3)}` + : resp.data.code; + const remaining = Math.max(0, resp.data.expires_at - Math.floor(Date.now() / 1000)); + cdEl.textContent = `${remaining}s`; + }; + + const onInput = () => { + lastSecret = input.value; + void tick(); + }; + input.addEventListener('input', onInput); + if (interval === null) { + interval = setInterval(() => { void tick(); }, 1000); + } + + return () => { + input.removeEventListener('input', onInput); + if (interval !== null) { clearInterval(interval); interval = null; } + row.hidden = true; + }; +} +``` + +- [ ] **Step 4: Add CSS** + +Append to `extension/src/popup/styles.css` AND `extension/src/vault/vault.css`: + +```css +.totp-preview { + margin-top: 6px; + padding: 6px 10px; + border: 1px dashed var(--border-subtle); + border-radius: 3px; + display: flex; + justify-content: space-between; + align-items: center; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} +.totp-code { + font-size: 14px; + font-weight: 600; + letter-spacing: 1px; + color: var(--accent); +} +.totp-countdown { + font-size: 11px; +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/totp-tools.test.ts -t wireTotpPreview` +Expected: PASS (3). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/form-affordances/totp-tools.ts extension/src/shared/form-affordances/__tests__/totp-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wireTotpPreview live ticker" +``` + +--- + +### Task 14: `wireTotpQr` affordance (C7) + `jsqr` lazy-load + +**Files:** +- Modify: `extension/src/shared/form-affordances/totp-tools.ts` +- Modify: `extension/src/shared/form-affordances/__tests__/totp-tools.test.ts` +- Modify: `extension/package.json` — add `jsqr ^1.4.0` +- Modify: `extension/src/popup/styles.css` (add `.totp-qr-panel`) +- Modify: `extension/src/vault/vault.css` (mirror) + +`◫` glyph button opens an inline panel below the totp-secret input. The panel listens for `paste` events, accepts `` upload, and accepts drag-drop. When an image arrives, lazy-load `jsqr` (`await import('jsqr')`), decode, parse the resulting URI as `otpauth://...`, extract the `secret` query param, fill `#f-totp`. On failure, show an inline error. + +The decode itself is non-trivially testable in vitest/happy-dom because canvas isn't available — the test harness mocks the `decodeQrFromBlob` helper that we factor out, and exercises the flow via that mock. + +- [ ] **Step 1: Write the failing test** + +Append to `totp-tools.test.ts`: + +```typescript +import { wireTotpQr } from '../totp-tools'; + +describe('wireTotpQr', () => { + let form: HTMLElement; + let decodeQrFromBlob: ReturnType; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + + `; + document.body.appendChild(form); + decodeQrFromBlob = vi.fn(); + }); + + it('toggles the panel on button click', () => { + wireTotpQr(form, { decodeQrFromBlob }); + const btn = form.querySelector('#totp-qr-btn') as HTMLButtonElement; + const panel = form.querySelector('#totp-qr-panel') as HTMLElement; + expect(panel.hidden).toBe(true); + btn.click(); + expect(panel.hidden).toBe(false); + btn.click(); + expect(panel.hidden).toBe(true); + }); + + it('fills f-totp on successful decode of otpauth:// URI', async () => { + decodeQrFromBlob.mockResolvedValue('otpauth://totp/Example:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example'); + wireTotpQr(form, { decodeQrFromBlob }); + const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement; + const fakeFile = new File(['x'], 'qr.png', { type: 'image/png' }); + Object.defineProperty(fileInput, 'files', { value: [fakeFile] }); + fileInput.dispatchEvent(new Event('change')); + await Promise.resolve(); await Promise.resolve(); + expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe('JBSWY3DPEHPK3PXP'); + }); + + it('shows error when QR decodes but is not otpauth://', async () => { + decodeQrFromBlob.mockResolvedValue('https://example.com/'); + wireTotpQr(form, { decodeQrFromBlob }); + const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] }); + fileInput.dispatchEvent(new Event('change')); + await Promise.resolve(); await Promise.resolve(); + const err = form.querySelector('#totp-qr-error') as HTMLElement; + expect(err.textContent).toMatch(/not a totp uri/i); + expect((form.querySelector('#f-totp') as HTMLInputElement).value).toBe(''); + }); + + it('shows error when decode returns null (no QR found)', async () => { + decodeQrFromBlob.mockResolvedValue(null); + wireTotpQr(form, { decodeQrFromBlob }); + const fileInput = form.querySelector('#totp-qr-file') as HTMLInputElement; + Object.defineProperty(fileInput, 'files', { value: [new File(['x'], 'x.png', { type: 'image/png' })] }); + fileInput.dispatchEvent(new Event('change')); + await Promise.resolve(); await Promise.resolve(); + const err = form.querySelector('#totp-qr-error') as HTMLElement; + expect(err.textContent).toMatch(/no qr found/i); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/totp-tools.test.ts -t wireTotpQr` +Expected: FAIL — `wireTotpQr` not exported. + +- [ ] **Step 3: Add jsqr dep** + +In `extension/package.json`, append to `devDependencies` (it's a runtime dep but webpack bundles it; matches `arboard`-equivalent treatment): + +Actually — since `jsqr` is bundled by webpack into the extension at build time, it should go in **`dependencies`**. Add a top-level `dependencies` block if not present: + +```json +"dependencies": { + "jsqr": "^1.4.0" +}, +``` + +Then run `cd extension && npm install jsqr@^1.4.0` to update `package-lock.json`. + +- [ ] **Step 4: Implement** + +Append to `extension/src/shared/form-affordances/totp-tools.ts`: + +```typescript +/// Lazy-load jsqr and decode a QR from a Blob/File. Returns the decoded +/// string, or null if no QR was found. +async function defaultDecodeQrFromBlob(blob: Blob): Promise { + const [{ default: jsQR }] = await Promise.all([import('jsqr')]); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const ctx = canvas.getContext('2d'); + if (!ctx) return null; + ctx.drawImage(bitmap, 0, 0); + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const result = jsQR(imageData.data, imageData.width, imageData.height); + return result?.data ?? null; +} + +export interface TotpQrOpts { + /// Inject a stub in tests where canvas + imports aren't available. + decodeQrFromBlob?: (blob: Blob) => Promise; +} + +export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void { + const btn = form.querySelector('#totp-qr-btn'); + const panel = form.querySelector('#totp-qr-panel'); + const fileInput = form.querySelector('#totp-qr-file'); + const errEl = form.querySelector('#totp-qr-error'); + const totpInput = form.querySelector('#f-totp'); + if (!btn || !panel || !fileInput || !errEl || !totpInput) return; + + const decode = opts.decodeQrFromBlob ?? defaultDecodeQrFromBlob; + + btn.addEventListener('click', () => { + panel.hidden = !panel.hidden; + errEl.textContent = ''; + }); + + const handleBlob = async (blob: Blob) => { + errEl.textContent = ''; + let decoded: string | null; + try { + decoded = await decode(blob); + } catch (e) { + errEl.textContent = `decode failed: ${e instanceof Error ? e.message : String(e)}`; + return; + } + if (!decoded) { + errEl.textContent = 'no QR found in image'; + return; + } + if (!decoded.startsWith('otpauth://')) { + errEl.textContent = 'not a TOTP URI (expected otpauth://...)'; + return; + } + try { + const u = new URL(decoded); + const secret = u.searchParams.get('secret'); + if (!secret) { + errEl.textContent = 'TOTP URI missing secret'; + return; + } + totpInput.value = secret; + totpInput.dispatchEvent(new Event('input', { bubbles: true })); // trigger preview + panel.hidden = true; + } catch { + errEl.textContent = 'TOTP URI did not parse'; + } + }; + + fileInput.addEventListener('change', () => { + const f = fileInput.files?.[0]; + if (f) void handleBlob(f); + }); + + panel.addEventListener('paste', (e) => { + const item = Array.from((e as ClipboardEvent).clipboardData?.items ?? []).find((i) => i.type.startsWith('image/')); + if (item) { + const blob = item.getAsFile(); + if (blob) void handleBlob(blob); + } + }); + + panel.addEventListener('dragover', (e) => { e.preventDefault(); }); + panel.addEventListener('drop', (e) => { + e.preventDefault(); + const f = (e as DragEvent).dataTransfer?.files?.[0]; + if (f) void handleBlob(f); + }); +} +``` + +- [ ] **Step 5: Add CSS** + +Append to popup/styles.css and vault/vault.css: + +```css +.totp-qr-panel { + margin-top: 6px; + padding: 10px; + border: 1px dashed var(--border-subtle); + border-radius: 3px; + background: var(--bg-input); +} +.totp-qr-panel input[type="file"] { + display: block; + font-family: inherit; + color: var(--text-muted); +} +.totp-qr-error { + margin-top: 6px; + font-size: 11px; + color: var(--danger, #c75a4f); +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/totp-tools.test.ts` +Expected: PASS (7 in file). + +- [ ] **Step 7: Commit** + +```bash +git add extension/package.json extension/package-lock.json extension/src/shared/form-affordances/totp-tools.ts extension/src/shared/form-affordances/__tests__/totp-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wireTotpQr (jsqr lazy-load) for QR -> otpauth:// fill" +``` + +--- + +### Task 15: CLI `--totp-qr ` flag on `add login` and `edit` + +**Files:** +- Modify: `crates/relicario-cli/Cargo.toml` — add `rqrr = "0.7"`, promote `image` from dev-dep to runtime dep +- Modify: `crates/relicario-cli/src/main.rs` — add `--totp-qr` flag on `AddKind::Login` and to `cmd_edit` interactive prompt +- Modify: `crates/relicario-cli/tests/smart_inputs.rs` — `--totp-qr` golden-path test using a synthetic QR PNG fixture +- Create: `crates/relicario-cli/tests/fixtures/totp.png` — generated by the test setup, not checked in by hand + +`add login --totp-qr ./qr.png` decodes the image with `rqrr`, parses the URI, extracts `secret`, and stores it as the item's TOTP. `edit --totp-qr` does the same on an existing login. + +- [ ] **Step 1: Write the failing test** + +Append to `tests/smart_inputs.rs`: + +```rust +#[test] +fn add_login_totp_qr_decodes_otpauth_uri() { + use tempfile::TempDir; + + // 1. Generate a QR PNG containing a known otpauth:// URI in a temp file. + // Use qrcode + image crates from dev-deps. Pseudo: + // + // let uri = "otpauth://totp/Example:alice?secret=JBSWY3DPEHPK3PXP&issuer=Example"; + // let code = qrcode::QrCode::new(uri).unwrap(); + // let img = code.render::>().module_dimensions(8, 8).build(); + // img.save(qr_path).unwrap(); + // + // Add `qrcode = "0.14"` to dev-deps if not already present. + + // 2. Init a vault, add login --totp-qr , then `get` and assert + // the totp secret matches JBSWY3DPEHPK3PXP. + + // (Implementer: see basic_flows.rs for the init + add + get pattern; this + // test simply substitutes --totp-qr for --totp-secret.) + let _tmp = TempDir::new().unwrap(); +} + +#[test] +fn add_login_totp_qr_errors_on_non_otpauth_qr() { + // Generate a QR with a non-otpauth payload (e.g. "https://example.com"). + // Assert the CLI exits nonzero with a "not a TOTP URI" message. +} +``` + +(The test fixture-generation pattern is duplicated across both tests; lift to a `fn make_test_qr(uri: &str) -> PathBuf` helper.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test -p relicario-cli --test smart_inputs add_login_totp_qr_` +Expected: FAIL — `--totp-qr` flag missing. + +- [ ] **Step 3: Add deps** + +In `crates/relicario-cli/Cargo.toml`: + +```toml +[dependencies] +# ... existing ... +rqrr = "0.7" +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } # promote from dev-dep, add png +``` + +In `[dev-dependencies]`, remove the `image` line (now in `[dependencies]`) and add: + +```toml +qrcode = "0.14" +``` + +- [ ] **Step 4: Add a `decode_totp_qr` helper** + +In `crates/relicario-cli/src/helpers.rs`: + +```rust +/// Decode a QR image at `path`. Returns the otpauth secret in base32 if the +/// QR decodes to an `otpauth://...` URI with a `secret` query param. +pub fn decode_totp_qr(path: &std::path::Path) -> anyhow::Result { + let img = image::open(path) + .map_err(|e| anyhow::anyhow!("failed to read image: {e}"))? + .to_luma8(); + let mut prepared = rqrr::PreparedImage::prepare(img); + let grids = prepared.detect_grids(); + let grid = grids.into_iter().next().ok_or_else(|| anyhow::anyhow!("no QR code found in image"))?; + let (_meta, content) = grid.decode().map_err(|e| anyhow::anyhow!("QR decode failed: {e}"))?; + if !content.starts_with("otpauth://") { + return Err(anyhow::anyhow!("not a TOTP URI (expected otpauth://...)")); + } + let parsed = url::Url::parse(&content).map_err(|e| anyhow::anyhow!("invalid otpauth URI: {e}"))?; + let secret = parsed + .query_pairs() + .find(|(k, _)| k == "secret") + .map(|(_, v)| v.to_string()) + .ok_or_else(|| anyhow::anyhow!("otpauth URI missing `secret` parameter"))?; + Ok(secret) +} +``` + +- [ ] **Step 5: Wire into `AddKind::Login` + `cmd_edit`** + +Find `AddKind::Login` in `main.rs` (around line 169) and add a flag: + +```rust + Login { + // ... existing fields ... + + /// Decode an `otpauth://` QR image to fill the TOTP secret. Mutually + /// exclusive with `--totp-secret` (if that exists; otherwise just + /// document the precedence). + #[arg(long, value_name = "PATH")] + totp_qr: Option, + }, +``` + +In the `cmd_add` dispatch for `AddKind::Login` (around line 479), if `totp_qr` is `Some`, call `helpers::decode_totp_qr(&path)` to get the secret and use it as if it were passed via `--totp-secret`. + +For `cmd_edit` (line 993), thread an analogous `--totp-qr` flag through the edit args struct. Look at how the existing edit flow accepts updates and slot in: + +```rust +if let Some(path) = totp_qr { + let secret = helpers::decode_totp_qr(&path)?; + // ... update the item's TotpConfig with the decoded secret ... +} +``` + +Edit flow detail: read the current `LoginCore`, replace `core.totp = Some(TotpConfig { secret: base32_decode(&secret)?, ... })` with the standard sha1/6/30s defaults. + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test -p relicario-cli --test smart_inputs add_login_totp_qr_` +Expected: PASS (2). + +- [ ] **Step 7: Run the full CLI suite to confirm no regressions** + +Run: `cargo test -p relicario-cli` +Expected: PASS (all existing + 2 new). + +- [ ] **Step 8: Commit** + +```bash +git add crates/relicario-cli/Cargo.toml crates/relicario-cli/src/main.rs crates/relicario-cli/src/helpers.rs crates/relicario-cli/tests/smart_inputs.rs Cargo.lock +git commit -m "cli: --totp-qr flag on add login + edit (rqrr decode)" +``` + +--- + +## Phase F — Notes monospace toggle + +### Task 16: `wireNotesMonoToggle` affordance (C8) + +**Files:** +- Create: `extension/src/shared/form-affordances/notes-tools.ts` +- Test: `extension/src/shared/form-affordances/__tests__/notes-tools.test.ts` +- Modify: `extension/src/popup/styles.css` (add `.notes-with-toggle`, `.f-notes--mono`) +- Modify: `extension/src/vault/vault.css` (mirror) + +`≡` glyph button next to the notes label toggles a `.f-notes--mono` class on the textarea. State is persisted to `chrome.storage.local` keyed by item ID (or a session key for the "add" form). + +- [ ] **Step 1: Write the failing test** + +```typescript +// extension/src/shared/form-affordances/__tests__/notes-tools.test.ts +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { wireNotesMonoToggle } from '../notes-tools'; + +describe('wireNotesMonoToggle', () => { + let form: HTMLElement; + let storage: { get: ReturnType; set: ReturnType }; + + beforeEach(() => { + form = document.createElement('div'); + form.innerHTML = ` + + + `; + document.body.appendChild(form); + storage = { + get: vi.fn().mockImplementation((_keys, cb) => cb({})), + set: vi.fn().mockImplementation((_obj, cb) => cb && cb()), + }; + (globalThis as any).chrome = { storage: { local: storage } }; + }); + + it('toggles class on click and persists', async () => { + await wireNotesMonoToggle(form, { itemId: 'abc123' }); + const btn = form.querySelector('#notes-mono-btn') as HTMLButtonElement; + const ta = form.querySelector('#f-notes') as HTMLTextAreaElement; + expect(ta.classList.contains('f-notes--mono')).toBe(false); + btn.click(); + expect(ta.classList.contains('f-notes--mono')).toBe(true); + expect(storage.set).toHaveBeenCalledWith({ 'notesMono.abc123': true }, expect.any(Function)); + }); + + it('restores prior state on mount', async () => { + storage.get.mockImplementation((_keys, cb) => cb({ 'notesMono.abc123': true })); + await wireNotesMonoToggle(form, { itemId: 'abc123' }); + const ta = form.querySelector('#f-notes') as HTMLTextAreaElement; + expect(ta.classList.contains('f-notes--mono')).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/notes-tools.test.ts` +Expected: FAIL — module missing. + +- [ ] **Step 3: Implement** + +```typescript +// extension/src/shared/form-affordances/notes-tools.ts + +export interface NotesMonoOpts { + /// Item ID for persistence — pass empty string for "add new" forms (state + /// is then session-scoped under the key 'notesMono.__new__'). + itemId: string; +} + +export async function wireNotesMonoToggle(form: HTMLElement, opts: NotesMonoOpts): Promise { + const btn = form.querySelector('#notes-mono-btn'); + const ta = form.querySelector('#f-notes'); + if (!btn || !ta) return; + + const key = `notesMono.${opts.itemId || '__new__'}`; + const stored = await new Promise((resolve) => { + chrome.storage.local.get([key], (result) => resolve(!!result[key])); + }); + if (stored) ta.classList.add('f-notes--mono'); + + btn.addEventListener('click', () => { + const next = !ta.classList.contains('f-notes--mono'); + ta.classList.toggle('f-notes--mono', next); + chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ }); + }); +} +``` + +- [ ] **Step 4: Add CSS** + +Append to popup/styles.css and vault/vault.css: + +```css +.notes-with-toggle { + display: flex; + align-items: center; + gap: 8px; +} +.f-notes--mono { + font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd extension && npx vitest run src/shared/form-affordances/__tests__/notes-tools.test.ts` +Expected: PASS (2). + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/shared/form-affordances/notes-tools.ts extension/src/shared/form-affordances/__tests__/notes-tools.test.ts extension/src/popup/styles.css extension/src/vault/vault.css +git commit -m "ext(affordances): wireNotesMonoToggle with chrome.storage.local persistence" +``` + +--- + +## Phase G — Login form integration + +### Task 17: Wire all 6 affordance modules into `login.ts renderForm()` + +**Files:** +- Modify: `extension/src/popup/components/types/login.ts` +- Modify: `extension/src/popup/components/types/__tests__/login.test.ts` (add integration test) + +This is the orchestration task. It updates the form HTML to include the affordance scaffolding (chip rows, glyph buttons, panels), wires each affordance, registers teardown for the ones that return a teardown fn, and hooks into the existing `teardown()` exit. + +- [ ] **Step 1: Write the failing integration test** + +```typescript +// extension/src/popup/components/types/__tests__/login.test.ts (append) +import { describe, it, expect, vi, beforeEach } from 'vitest'; +// ... existing imports for renderForm, etc. + +describe('login form smart inputs', () => { + beforeEach(() => { + document.body.innerHTML = '
'; + // Stub chrome runtime / sendMessage as the test file already does for + // existing tests (look for the `mockSendMessage` helper). + }); + + it('renders all 6 smart-input slots in the form', async () => { + // Render the add-login form (mode='add', existing=null). + // Adapt this to the existing test pattern in this file. + // Then assert the DOM contains: + // - #fill-from-tab-btn + // - #hostname-chip-row + // - #f-group with list="groups-datalist" + // - #reveal-password-btn + // - #strength-bar-row + // - #f-totp + #totp-preview-row + #totp-qr-btn + #totp-qr-panel + // - #notes-mono-btn + expect(document.querySelector('#fill-from-tab-btn')).not.toBeNull(); + expect(document.querySelector('#hostname-chip-row')).not.toBeNull(); + expect(document.querySelector('#reveal-password-btn')).not.toBeNull(); + expect(document.querySelector('#strength-bar-row')).not.toBeNull(); + expect(document.querySelector('#totp-preview-row')).not.toBeNull(); + expect(document.querySelector('#totp-qr-btn')).not.toBeNull(); + expect(document.querySelector('#totp-qr-panel')).not.toBeNull(); + expect(document.querySelector('#notes-mono-btn')).not.toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "smart inputs"` +Expected: FAIL. + +- [ ] **Step 3: Update form HTML** + +In `extension/src/popup/components/types/login.ts`, in `renderForm()`, replace the `app.innerHTML = ...` block. The full updated form (preserving existing structure, adding affordance hooks): + +```typescript + app.innerHTML = ` +
+ ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })} + ${state.error ? `
${escapeHtml(state.error)}
` : ''} + +
+
+ +
+ +
+ + +
+ +
+ +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + +
+ + +
+ +
+
+ +
+
+ + +
+ +
+ + ${renderSectionsEditor(sectionsDraft, sectionsExpanded)} + ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''} +
+ + +
+
+ `; +``` + +(Note the `↻` swap on the gen button — Phase 1 introduced `GLYPH_GENERATE` for this; the inline `✨` was a leftover.) + +- [ ] **Step 4: Wire the affordances** + +After the existing `wireSectionsEditor(...)` and disclosure-wiring blocks, before the cancel/save handlers, insert: + +```typescript + // ---- Smart input affordances ------------------------------------------ + // Each wireXxx call attaches event listeners to the just-rendered form. + // Affordances that hold timers/intervals return a teardown fn we collect + // here and run from the form's existing teardown() entry point. + const affordanceTeardowns: Array<() => void> = []; + + wireFillFromTab(app, { sendMessage }); + wireHostnameChip(app); + void wireGroupAutocomplete(app, { sendMessage }); + affordanceTeardowns.push(wirePasswordReveal(app)); + wirePasswordStrength(app, { scheduleRate }); + affordanceTeardowns.push(wireTotpPreview(app, { sendMessage })); + wireTotpQr(app); + void wireNotesMonoToggle(app, { itemId: existing?.id ?? '' }); + + // Stash teardown-runner so the existing `teardown()` (line 28) calls it. + pendingAffordanceTeardowns = affordanceTeardowns; +``` + +Add at module scope, alongside the existing `let totpTickerId / activeKeyHandler / activeFormEscHandler / sectionsExpanded`: + +```typescript +let pendingAffordanceTeardowns: Array<() => void> = []; +``` + +In the existing `teardown()` (line 28), add at the top: + +```typescript + for (const fn of pendingAffordanceTeardowns) { + try { fn(); } catch { /* best effort */ } + } + pendingAffordanceTeardowns = []; +``` + +Top-of-file imports (add to existing import block): + +```typescript +import { wireFillFromTab, wireHostnameChip } from '../../../shared/form-affordances/url-tools'; +import { wireGroupAutocomplete } from '../../../shared/form-affordances/group-autocomplete'; +import { wirePasswordReveal, wirePasswordStrength } from '../../../shared/form-affordances/password-tools'; +import { wireTotpPreview, wireTotpQr } from '../../../shared/form-affordances/totp-tools'; +import { wireNotesMonoToggle } from '../../../shared/form-affordances/notes-tools'; +import { scheduleRate } from '../../../setup/setup-helpers'; +``` + +- [ ] **Step 5: Run integration test to verify it passes** + +Run: `cd extension && npx vitest run src/popup/components/types/__tests__/login.test.ts -t "smart inputs"` +Expected: PASS. + +- [ ] **Step 6: Run full extension suite** + +Run: `cd extension && npm test` +Expected: PASS (all prior + new tests). If a snapshot or DOM-shape test breaks because the form HTML changed, update the snapshot — the new HTML is the new reality. + +- [ ] **Step 7: Commit** + +```bash +git add extension/src/popup/components/types/login.ts extension/src/popup/components/types/__tests__/login.test.ts +git commit -m "ext(login): wire 8 smart-input affordances into renderForm()" +``` + +--- + +## Phase H — Final regression + docs + +### Task 18: Full-suite regression + manual QA + +**Files:** +- (None modified — verification + manual smoke testing) + +- [ ] **Step 1: Run full extension test suite** + +Run: `cd extension && npm test` +Expected: PASS (all). + +- [ ] **Step 2: Run full Cargo suite** + +Run: `cargo test` +Expected: PASS (all crates). + +- [ ] **Step 3: Build the extension** + +Run: `cd extension && npm run build:wasm && npm run build` +Expected: success with no warnings beyond pre-existing ones. + +- [ ] **Step 4: Manual QA pass — popup** + +Load the unpacked extension in Chrome from `extension/dist/`. Click the toolbar icon, unlock, and: + +| Affordance | Verify | +|---|---| +| C1 fill-from-tab | On a real tab, `⤓` populates URL + (empty) title | +| C2 hostname chip | Typing `gitlab.com` → debounced chip + bare host appears | +| C3 group autocomplete | `` in the group field shows existing groups | +| C4 reveal toggle | `⊙` ↔ `⊘` flips input.type; navigating away resets to password | +| C5 strength bar | Bar fills 0..score; label includes `~10^N` | +| C6 totp preview | Pasting `JBSWY3DPEHPK3PXP` shows `492 837 · Ns`, ticks every second | +| C7 totp QR | `◫` opens panel; pasting/uploading/dropping a QR PNG fills the secret | +| C8 notes monospace | `≡` toggles font; reload form → state persists | + +- [ ] **Step 5: Manual QA pass — fullscreen tab** + +Open the same vault in `vault.html` (right-click toolbar icon → "Open vault tab" or whichever entrypoint is wired). Repeat the affordance checks. Confirm the popout `⤴` button is absent (Phase 1 already removed it from fullscreen). + +- [ ] **Step 6: Manual CLI parity smoke test** + +```bash +# Init a temp vault and add an item. +cargo run -p relicario-cli -- rate "weak" +cargo run -p relicario-cli -- rate "correct horse battery staple table cocoa rocket" +cargo run -p relicario-cli -- completions bash | head -5 + +# Generate a QR with python or imagemagick: +qrencode -o /tmp/qr.png "otpauth://totp/Test:alice?secret=JBSWY3DPEHPK3PXP&issuer=Test" +# Then use --totp-qr in an existing vault. + +# Check the cache file appears after a list: +ls $RELICARIO_VAULT/.relicario/groups.cache +``` + +- [ ] **Step 7: Commit any docs updates** + +If you discovered a doc gap (e.g. README missing a mention of `groups.cache`), patch and commit: + +```bash +git add README.md +git commit -m "docs: note groups.cache plaintext leak in completions help" +``` + +- [ ] **Step 8: Tag the branch ready-to-merge** + +```bash +git log --oneline | head -20 # sanity check the commit list +git tag plan-2a-smart-inputs-complete +``` + +--- + +## Self-Review Notes + +**Spec coverage:** + +| Spec item | Task | +|---|---| +| C1 fill URL from current tab | Tasks 2, 3 | +| C2 hostname chip | Task 4 | +| C3 group autocomplete | Tasks 5, 6 | +| C4 password reveal toggle | Task 9 | +| C5 inline strength bar | Task 10 | +| C6 TOTP live code preview | Tasks 12, 13 | +| C7 TOTP from QR image | Task 14 | +| C8 notes monospace toggle | Task 16 | +| CLI parity: `relicario rate` | Task 11 | +| CLI parity: `--totp-qr` flag | Task 15 | +| CLI parity: shell completion + dynamic group | Tasks 7, 8 | +| Form integration | Task 17 | +| Regression + docs | Task 18 | + +**Placeholder scan:** Two tasks (8, 15) have test-skeleton sketches with comments like "Implementer: see basic_flows.rs for the init helper." This is intentional — those tests need to compose with the existing test-helper infrastructure that I haven't read in full. The implementer should look up `tests/basic_flows.rs::init_test_vault()` (or equivalent) and either lift it to `tests/common/mod.rs` or copy the minimal init sequence inline. **If the helper does not exist, that is the trigger to create it** rather than a reason to skip the test. + +**Type consistency:** All affordance modules use the same opts shape — a single `sendMessage` parameter where SW round-trips are needed, plus inputs typed by the message-bus union. The login form's `teardown()` entry collects affordance teardown fns into a module-level `pendingAffordanceTeardowns` array (mirrors the existing `activeKeyHandler` pattern). + +**One architectural call worth flagging at execution time:** Task 17 routes the affordance teardowns through a new module-scope `pendingAffordanceTeardowns` array. If a future refactor moves `login.ts` toward a class/instance model, this static-state pattern will become awkward — but it matches the file's existing `let totpTickerId / activeKeyHandler` pattern, so it stays internally consistent. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md`. + +Per `feedback_subagent_default`, this will execute via **superpowers:subagent-driven-development** unless you say otherwise — fresh subagent per task with two-stage review between tasks. + +Ready to execute on your signal.