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