Merge feature/fullscreen-ux-phase-2a: smart-input affordances

Phase 2A of the fullscreen UX redesign — 8 form-level smart-input
affordances (URL fill-from-tab + hostname chip, group autocomplete,
password reveal + strength bar, TOTP live preview + QR decode, notes
monospace toggle), shared between popup and fullscreen vault tabs via
the new extension/src/shared/form-affordances/ module set.

CLI parity:
- relicario rate <passphrase> (zxcvbn score / guess estimate)
- relicario completions <SHELL> (bash/zsh/fish via clap_complete)
- --group <TAB> dynamic enumeration via .relicario/groups.cache
  (plaintext leak surface; opt out with RELICARIO_NO_GROUPS_CACHE=1)
- --totp-qr <path> on add login + edit (rqrr decode)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-01 22:37:18 -04:00
26 changed files with 5395 additions and 23 deletions

View File

@@ -0,0 +1,122 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../../../shared/state', async () => {
const navigate = vi.fn();
const setState = vi.fn();
const sendMessage = vi.fn();
const getState = vi.fn(() => ({
view: 'add', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0,
searchQuery: '', activeGroup: null, error: null, loading: false,
capturedTabId: null, capturedUrl: '', newType: 'login',
generatorDefaults: null,
}));
const escapeHtml = (s: string) => s
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return {
navigate, setState, sendMessage, getState, escapeHtml,
popOutToTab: vi.fn(), isInTab: vi.fn(() => false), openVaultTab: vi.fn(),
};
});
// Mock setup-helpers (scheduleRate used by wirePasswordStrength)
vi.mock('../../../../setup/setup-helpers', () => ({
scheduleRate: vi.fn(),
STRENGTH_LABELS: {},
entropyText: vi.fn(() => ''),
}));
import { renderForm } from '../login';
import { sendMessage } from '../../../../shared/state';
describe('login form smart inputs', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
// chrome.storage.local stub (needed by wireNotesMonoToggle)
(globalThis as any).chrome = {
storage: {
local: {
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
},
},
runtime: {
sendMessage: vi.fn(),
},
};
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { groups: [] } });
});
it('renders all 6 smart-input slots in the form', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
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();
});
});
describe('Login save shape', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="app"></div>';
(globalThis as any).chrome = {
storage: {
local: {
get: vi.fn().mockImplementation((_keys: any, cb: any) => cb({})),
set: vi.fn().mockImplementation((_obj: any, cb: any) => cb && cb()),
},
},
runtime: { sendMessage: vi.fn() },
};
vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
if (msg.type === 'list_groups') return { ok: true, data: { groups: [] } };
if (msg.type === 'preview_totp_from_secret') return { ok: false };
return { ok: true, data: { id: 'fakeid0000000000', items: [] } };
});
});
it('saves a login item with url, username, and password', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
(document.getElementById('f-title') as HTMLInputElement).value = 'GitHub';
(document.getElementById('f-url') as HTMLInputElement).value = 'https://github.com/login';
(document.getElementById('f-username') as HTMLInputElement).value = 'alice';
(document.getElementById('f-password') as HTMLInputElement).value = 'hunter2';
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeDefined();
const msg = addCall![0] as { type: 'add_item'; item: any };
expect(msg.item.type).toBe('login');
expect(msg.item.core).toMatchObject({
type: 'login',
username: 'alice',
password: 'hunter2',
url: 'https://github.com/login',
});
});
it('rejects save when title is empty', async () => {
const app = document.getElementById('app')!;
renderForm(app, 'add', null);
document.getElementById('save-btn')!.click();
await new Promise(r => setTimeout(r, 5));
const calls = vi.mocked(sendMessage).mock.calls;
const addCall = calls.find(([msg]) => (msg as { type: string }).type === 'add_item');
expect(addCall).toBeUndefined();
});
});

View File

@@ -22,10 +22,20 @@ import {
wireAttachmentsDisclosure,
teardownAttachmentsDisclosure,
} from '../attachments-disclosure';
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';
/// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached.
export function teardown(): void {
for (const fn of pendingAffordanceTeardowns) {
try { fn(); } catch { /* best effort */ }
}
pendingAffordanceTeardowns = [];
teardownAttachmentsDisclosure();
stopTotpTicker();
if (activeKeyHandler) {
@@ -202,6 +212,7 @@ let totpTickerId: ReturnType<typeof setInterval> | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
let activeFormEscHandler: ((e: KeyboardEvent) => void) | null = null;
let sectionsExpanded = false;
let pendingAffordanceTeardowns: Array<() => void> = [];
function stopTotpTicker(): void {
if (totpTickerId !== null) { clearInterval(totpTickerId); totpTickerId = null; }
}
@@ -247,23 +258,63 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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>
<input id="f-url" type="text" value="${escapeHtml(url)}" placeholder="https://github.com/login"></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="form-group">
<label class="label" for="f-password">password</label>
<div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}">
<button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false"></button>
</div></div>
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label>
<input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
<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"><label class="label" for="f-notes">notes</label>
<textarea id="f-notes" placeholder="recovery codes, security questions...">${escapeHtml(notes)}</textarea></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">
@@ -305,6 +356,26 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
wireDisclosure();
}
// ---- 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> = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sm = sendMessage as any;
wireFillFromTab(app, { sendMessage: sm });
wireHostnameChip(app);
void wireGroupAutocomplete(app, { sendMessage: sm });
affordanceTeardowns.push(wirePasswordReveal(app));
wirePasswordStrength(app, { scheduleRate });
affordanceTeardowns.push(wireTotpPreview(app, { sendMessage: sm }));
wireTotpQr(app);
void wireNotesMonoToggle(app, { itemId: existing?.id ?? '' });
// Stash teardown-runner so the existing `teardown()` calls it.
pendingAffordanceTeardowns = affordanceTeardowns;
document.getElementById('gen-btn')?.addEventListener('click', (e) => {
const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {

View File

@@ -10,6 +10,7 @@
--bg-page: #0d1117;
--bg-pane: #161b22;
--bg-elevated: #21262d;
--bg-input: #161b22;
--border-subtle: #30363d;
/* Text */
@@ -1332,3 +1333,127 @@ textarea {
padding: 1px 5px;
border-radius: 3px;
}
/* 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;
}
.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;
}
.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;
}
.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;
}
.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);
}
.notes-with-toggle {
display: flex;
align-items: center;
gap: 8px;
}
.f-notes--mono {
font-family: ui-monospace, "JetBrains Mono", "SF Mono", monospace !important;
}