Files
relicario/docs/superpowers/plans/2026-05-01-fullscreen-ux-phase-2a-smart-inputs.md
adlee-was-taken 79b10d6a18 docs(plans): fullscreen UX Phase 2A — smart inputs
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 <noreply@anthropic.com>
2026-05-01 16:38:34 -04:00

92 KiB

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 <passphrase>, --totp-qr <path>, 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 <passphrase> subcommand, a --totp-qr <path> flag on add login / edit (decoded via rqrr), and a relicario completions <SHELL> 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 <input type="password"> 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.tswireFillFromTab, wireHostnameChip
  • extension/src/shared/form-affordances/group-autocomplete.tswireGroupAutocomplete
  • extension/src/shared/form-affordances/password-tools.tswirePasswordReveal, wirePasswordStrength
  • extension/src/shared/form-affordances/totp-tools.tswireTotpPreview, wireTotpQr
  • extension/src/shared/form-affordances/notes-tools.tswireNotesMonoToggle
  • 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
// 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
// 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
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
// 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:

  | { 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():

    case 'get_active_tab_url': {
      const tabs = await new Promise<chrome.tabs.Tab[]>((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
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
// 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<typeof vi.fn>;

  beforeEach(() => {
    form = document.createElement('div');
    form.innerHTML = `
      <input id="f-title" type="text" />
      <div class="inline-row">
        <input id="f-url" type="text" />
        <button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
      </div>
    `;
    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
// 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<HTMLButtonElement>('#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<HTMLInputElement>('#f-url');
    const titleEl = form.querySelector<HTMLInputElement>('#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 = `<button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">${GLYPH_FILL_FROM_TAB}</button>`;
  • 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):

/* 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
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
// 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 = `
      <div class="form-group">
        <input id="f-url" type="text" />
        <div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
      </div>
    `;
    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:

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<HTMLInputElement>('#f-url');
  const row = form.querySelector<HTMLElement>('#hostname-chip-row');
  if (!input || !row) return;
  let timer: ReturnType<typeof setTimeout> | 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 = `<span class="hostname-chip" style="background:${hue};">${initial}</span><span class="hostname-text">${host}</span>`;
  };

  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:

.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
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
// 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:

  | { type: 'list_groups' }
  • Step 4: Implement the handler

Add new arm in popup-only.ts handle():

    case 'list_groups': {
      if (!state.manifest) return { ok: true, data: { groups: [] } };
      const set = new Set<string>();
      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
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 <datalist id="groups-datalist"> and sets list="groups-datalist" on the group input. Browser handles the dropdown UI.

  • Step 1: Write the failing test
// 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 = `<input id="f-group" type="text" />`;
    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
// 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<void> {
  const input = form.querySelector<HTMLInputElement>('#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) => `<option value="${g.replace(/"/g, '&quot;')}"></option>`).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
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 <datalist>"

Task 7: CLI relicario completions <SHELL> 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
// 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 _<name>
        .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]:

clap_complete = "4"
  • Step 4: Add subcommand

In crates/relicario-cli/src/main.rs:

Near the top with the other use statements:

use clap_complete::{generate, Shell};

In the enum Command body (around line 24), add:

    /// 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:

        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
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 <SHELL>' subcommand via clap_complete"

Task 8: Plaintext groups.cache for dynamic --group <TAB> 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 <vault_dir>/.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:

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 <test-image>
    //   relicario add login --title T --group work --password p
    //   relicario list
    // Then assert <vault>/.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 <vault>/.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:

use std::path::PathBuf;
use std::collections::BTreeSet;

/// Path to the plaintext `groups.cache` file used by shell completion to
/// enumerate `--group <TAB>` 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<String>) -> 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:

fn refresh_groups_cache(vault_dir: &std::path::Path, manifest: &relicario_core::Manifest) {
    let mut set = std::collections::BTreeSet::<String>::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:

    /// Emit a shell completion script for the given shell.
    ///
    /// For `--group <TAB>` 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 cats 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
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
// 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 = `
      <input id="f-password" type="password" value="secret" />
      <button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
    `;
    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
// 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<HTMLButtonElement>('#reveal-password-btn');
  const input = form.querySelector<HTMLInputElement>('#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
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:

import { wirePasswordStrength } from '../password-tools';

describe('wirePasswordStrength', () => {
  let form: HTMLElement;
  let scheduleRate: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    form = document.createElement('div');
    form.innerHTML = `
      <input id="f-password" type="password" value="" />
      <div id="strength-bar-row" class="strength-bar-row" hidden>
        <div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
        <div class="strength-label"></div>
      </div>
    `;
    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:

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<HTMLInputElement>('#f-password');
  const row = form.querySelector<HTMLElement>('#strength-bar-row');
  if (!input || !row) return;
  const bar = row.querySelector<HTMLElement>('.strength-bar');
  const label = row.querySelector<HTMLElement>('.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:

.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
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 <passphrase> 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:

#[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:

    /// 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:

        Command::Rate { passphrase } => cmd_rate(passphrase),

Implementation:

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
git add crates/relicario-cli/src/main.rs crates/relicario-cli/tests/smart_inputs.rs
git commit -m "cli: add 'rate <passphrase>' 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:

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:

  | { 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:

import { base32Decode } from '../../shared/base32';

Then add to the handle() switch:

    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
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
// 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<typeof vi.fn>;

  beforeEach(() => {
    form = document.createElement('div');
    form.innerHTML = `
      <input id="f-totp" type="text" value="" />
      <div id="totp-preview-row" class="totp-preview" hidden>
        <span class="totp-code">…</span>
        <span class="totp-countdown">…</span>
      </div>
    `;
    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
// 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<HTMLInputElement>('#f-totp');
  const row = form.querySelector<HTMLElement>('#totp-preview-row');
  if (!input || !row) return () => {};
  const codeEl = row.querySelector<HTMLElement>('.totp-code');
  const cdEl = row.querySelector<HTMLElement>('.totp-countdown');
  if (!codeEl || !cdEl) return () => {};

  let interval: ReturnType<typeof setInterval> | 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:

.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
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 <input type="file" accept="image/*"> 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:

import { wireTotpQr } from '../totp-tools';

describe('wireTotpQr', () => {
  let form: HTMLElement;
  let decodeQrFromBlob: ReturnType<typeof vi.fn>;

  beforeEach(() => {
    form = document.createElement('div');
    form.innerHTML = `
      <input id="f-totp" type="text" value="" />
      <button id="totp-qr-btn" class="glyph-btn" type="button" title="QR">◫</button>
      <div id="totp-qr-panel" class="totp-qr-panel" hidden>
        <input id="totp-qr-file" type="file" accept="image/*" />
        <div id="totp-qr-error" class="totp-qr-error"></div>
      </div>
    `;
    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:

"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:

/// 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<string | null> {
  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<string | null>;
}

export function wireTotpQr(form: HTMLElement, opts: TotpQrOpts = {}): void {
  const btn = form.querySelector<HTMLButtonElement>('#totp-qr-btn');
  const panel = form.querySelector<HTMLElement>('#totp-qr-panel');
  const fileInput = form.querySelector<HTMLInputElement>('#totp-qr-file');
  const errEl = form.querySelector<HTMLElement>('#totp-qr-error');
  const totpInput = form.querySelector<HTMLInputElement>('#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:

.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
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 <path> 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:

#[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::<image::Luma<u8>>().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 <qr_path>, 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:

[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:

qrcode = "0.14"
  • Step 4: Add a decode_totp_qr helper

In crates/relicario-cli/src/helpers.rs:

/// 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<String> {
    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:

        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<PathBuf>,
        },

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:

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
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 <path> 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
// 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<typeof vi.fn>; set: ReturnType<typeof vi.fn> };

  beforeEach(() => {
    form = document.createElement('div');
    form.innerHTML = `
      <button id="notes-mono-btn" class="glyph-btn" type="button" title="monospace">≡</button>
      <textarea id="f-notes"></textarea>
    `;
    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
// 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<void> {
  const btn = form.querySelector<HTMLButtonElement>('#notes-mono-btn');
  const ta = form.querySelector<HTMLTextAreaElement>('#f-notes');
  if (!btn || !ta) return;

  const key = `notesMono.${opts.itemId || '__new__'}`;
  const stored = await new Promise<boolean>((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:

.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
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
// 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 = '<div id="app"></div>';
    // 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):

  app.innerHTML = `
    <div class="pad">
      ${renderFormHeader({ titleText: mode === 'add' ? 'new login' : 'edit login' })}
      ${state.error ? `<div class="error">${escapeHtml(state.error)}</div>` : ''}

      <div class="form-group"><label class="label" for="f-title">title ${REQUIRED_PILL_HTML}</label>
        <input id="f-title" type="text" value="${escapeHtml(title)}" placeholder="GitHub"></div>

      <div class="form-group">
        <label class="label" for="f-url">url</label>
        <div class="inline-row">
          <input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login">
          <button id="fill-from-tab-btn" class="glyph-btn" type="button" title="fill from active tab">⤓</button>
        </div>
        <div id="hostname-chip-row" class="hostname-chip-row" hidden></div>
      </div>

      <div class="form-group"><label class="label" for="f-username">username</label>
        <input id="f-username" type="text" value="${escapeHtml(username)}" placeholder="alice@example.com"></div>

      <div class="form-group">
        <label class="label" for="f-password">password</label>
        <div class="inline-row">
          <input id="f-password" type="password" value="${escapeHtml(password)}">
          <button id="reveal-password-btn" class="glyph-btn" type="button" title="reveal">⊙</button>
          <button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">↻</button>
        </div>
        <div id="strength-bar-row" class="strength-bar-row" hidden>
          <div class="strength-bar"><span></span><span></span><span></span><span></span><span></span></div>
          <div class="strength-label"></div>
        </div>
      </div>

      <div class="form-group">
        <label class="label" for="f-totp">totp secret (base32)</label>
        <div class="inline-row">
          <input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP">
          <button id="totp-qr-btn" class="glyph-btn" type="button" title="paste / upload QR">◫</button>
        </div>
        <div id="totp-preview-row" class="totp-preview" hidden>
          <span class="totp-code">…</span>
          <span class="totp-countdown">…</span>
        </div>
        <div id="totp-qr-panel" class="totp-qr-panel" hidden>
          <input id="totp-qr-file" type="file" accept="image/*" />
          <div style="font-size:10px;color:var(--text-dim,#6b7888);margin-top:4px;">paste image, drop image, or pick a file</div>
          <div id="totp-qr-error" class="totp-qr-error"></div>
        </div>
      </div>

      <div class="form-group"><label class="label" for="f-group">group</label>
        <input id="f-group" type="text" value="${escapeHtml(group)}" placeholder="work"></div>

      <div class="form-group">
        <div class="notes-with-toggle">
          <label class="label" for="f-notes" style="margin:0;flex:1;">notes</label>
          <button id="notes-mono-btn" class="glyph-btn" type="button" title="toggle monospace">≡</button>
        </div>
        <textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea>
      </div>

      ${renderSectionsEditor(sectionsDraft, sectionsExpanded)}
      ${isInTab() ? renderAttachmentsDisclosure({ itemId: existing?.id ?? '', attachments: attachmentsDraft, mode: 'edit' }) : ''}
      <div class="form-actions">
        <button class="btn" id="cancel-btn">cancel</button>
        <button class="btn btn-primary" id="save-btn">${state.loading ? '<span class="spinner"></span>' : 'save'}</button>
      </div>
    </div>
  `;

(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:

  // ---- 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:

let pendingAffordanceTeardowns: Array<() => void> = [];

In the existing teardown() (line 28), add at the top:

  for (const fn of pendingAffordanceTeardowns) {
    try { fn(); } catch { /* best effort */ }
  }
  pendingAffordanceTeardowns = [];

Top-of-file imports (add to existing import block):

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
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 <TAB> 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
# 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:

git add README.md
git commit -m "docs: note groups.cache plaintext leak in completions help"
  • Step 8: Tag the branch ready-to-merge
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.