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;
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// --- Mocks (must be declared before `route` is imported so the router's
// `import * as vault` / `import * as session` resolve to these doubles) ---
@@ -921,3 +921,120 @@ describe('parse_lastpass_csv / import_lastpass_commit sender check', () => {
expect(result).toEqual({ ok: false, error: 'unauthorized_sender' });
});
});
// --- get_active_tab_url ---
describe('get_active_tab_url', () => {
let originalChrome: any;
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
afterEach(() => { (globalThis as any).chrome = originalChrome; });
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 route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
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 route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
expect(resp.ok).toBe(true);
expect(resp.data).toBeNull();
});
it('get_active_tab_url returns null for view-source: URLs', async () => {
(globalThis as any).chrome = {
...((globalThis as any).chrome ?? {}),
tabs: {
query: (q: any, cb: (tabs: any[]) => void) => {
cb([{ url: 'view-source:https://github.com/login', title: 'View Source' }]);
},
},
};
const resp = await route({ type: 'get_active_tab_url' } as any, makeState(), makePopupSender());
expect(resp.ok).toBe(true);
expect(resp.data).toBeNull();
});
});
// --- list_groups ---
describe('list_groups', () => {
it('list_groups returns deduplicated sorted groups from manifest', async () => {
const state = makeState();
state.manifest = {
schema_version: 2,
items: {
a: { id: 'a', title: 't1', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
b: { id: 'b', title: 't2', type: 'login', group: 'personal', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
c: { id: 'c', title: 't3', type: 'login', group: 'work', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] },
d: { id: 'd', title: 't4', type: 'login', tags: [], modified: 0, created: 0, favorite: false, attachment_summaries: [] }, // no group
},
};
const resp = await route({ type: 'list_groups' } as any, state, makePopupSender());
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 route({ type: 'list_groups' } as any, state, makePopupSender());
expect(resp.ok).toBe(true);
expect(resp.data).toEqual({ groups: [] });
});
});
// --- preview_totp_from_secret ---
describe('preview_totp_from_secret', () => {
let originalChrome: any;
beforeEach(() => { originalChrome = (globalThis as any).chrome; });
afterEach(() => { (globalThis as any).chrome = originalChrome; });
it('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 route(
{ type: 'preview_totp_from_secret', secret_b32: 'JBSWY3DPEHPK3PXP' } as any,
state, makePopupSender(),
);
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('rejects invalid base32', async () => {
const state = makeState();
state.wasm = { totp_compute: vi.fn() };
const resp = await route(
{ type: 'preview_totp_from_secret', secret_b32: 'too-short!!!' } as any,
state, makePopupSender(),
);
expect(resp.ok).toBe(false);
expect(resp.error).toMatch(/invalid/i);
expect(state.wasm.totp_compute).not.toHaveBeenCalled();
});
});

View File

@@ -6,6 +6,7 @@
import type { PopupMessage, Response } from '../../shared/messages';
import type { Item, ItemId, Manifest, VaultConfig, SetupState, DeviceSettings, TotpConfig, AttachmentRef } from '../../shared/types';
import { DEFAULT_DEVICE_SETTINGS } from '../../shared/types';
import { base32Decode } from '../../shared/base32';
import type { GitHost } from '../git-host';
import { createGitHost, base64ToUint8Array } from '../git-host';
import * as vault from '../vault';
@@ -146,6 +147,52 @@ export async function handle(
case 'rate_passphrase':
return { ok: true, data: state.wasm.rate_passphrase(msg.passphrase) };
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|chrome-search|moz-extension|edge|edge-extension|about|file|view-source|data|devtools|javascript):/i.test(tab.url)) {
return { ok: true, data: null };
}
return { ok: true, data: { url: tab.url, title: tab.title ?? '' } };
}
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() } };
}
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 } };
}
case 'generate_password': {
const password = state.wasm.generate_password(JSON.stringify(msg.request));
return { ok: true, data: { password } };

View File

@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wireGroupAutocomplete } from '../group-autocomplete';
describe('wireGroupAutocomplete', () => {
let form: HTMLElement;
beforeEach(() => {
// Clean up any datalist from a prior test
document.getElementById('groups-datalist')?.remove();
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();
});
});

View File

@@ -0,0 +1,8 @@
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');
});
});

View File

@@ -0,0 +1,38 @@
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);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { wirePasswordReveal, wirePasswordStrength } 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);
});
afterEach(() => {
document.body.removeChild(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;
const input = form.querySelector('#f-password') as HTMLInputElement;
btn.click(); // now revealed
expect(input.type).toBe('text');
teardown();
expect(input.type).toBe('password');
expect(btn.textContent).toBe('⊙');
expect(btn.title).toBe('reveal');
});
});
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();
});
afterEach(() => {
document.body.removeChild(form);
});
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);
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wireTotpPreview, wireTotpQr } 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);
});
});
describe('wireTotpQr', () => {
let form: HTMLElement;
let decodeQrFromBlob: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useRealTimers();
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);
});
});

View File

@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { wireFillFromTab, wireHostnameChip } 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);
});
});
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');
});
});

View File

@@ -0,0 +1,28 @@
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;
let resp: Awaited<ReturnType<typeof opts.sendMessage>>;
try {
resp = await opts.sendMessage({ type: 'list_groups' });
} catch {
return;
}
if (!resp?.ok || !resp.data?.groups) 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);
}

View File

@@ -0,0 +1,5 @@
/// 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 {};

View File

@@ -0,0 +1,29 @@
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__'}`;
// chrome.storage may be absent in test environments — guard gracefully.
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
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);
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
chrome.storage.local.set({ [key]: next }, () => { /* fire and forget */ });
}
});
}

View File

@@ -0,0 +1,67 @@
import { GLYPH_REVEAL, GLYPH_HIDE } from '../glyphs';
import { STRENGTH_LABELS, entropyText, type Strength } from '../../setup/setup-helpers';
/// 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';
};
}
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();
}

View File

@@ -0,0 +1,142 @@
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;
};
}
/// 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);
});
}

View File

@@ -0,0 +1,79 @@
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>`;
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);
const host = u.host || null;
if (!host) return null;
// Validate hostname contains only valid characters
if (!/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/.test(host)) {
return null;
}
return host;
} 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
}

View File

@@ -36,6 +36,8 @@ export type PopupMessage =
| { type: 'update_vault_settings'; settings: VaultSettings }
| { type: 'get_blacklist' }
| { type: 'remove_blacklist'; hostname: string }
| { type: 'get_active_tab_url' }
| { type: 'list_groups' }
| { type: 'upload_attachment'; itemId: string; filename: string; mimeType: string; bytes: ArrayBuffer }
| { type: 'download_attachment'; itemId: string; attachmentId: string }
| { type: 'list_devices' }
@@ -57,7 +59,8 @@ export type PopupMessage =
newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string };
}
| { type: 'parse_lastpass_csv'; bytes: ArrayBuffer }
| { type: 'import_lastpass_commit'; items: Item[] };
| { type: 'import_lastpass_commit'; items: Item[] }
| { type: 'preview_totp_from_secret'; secret_b32: string };
// --- Messages a content script may send ---
@@ -157,13 +160,14 @@ export const POPUP_ONLY_TYPES: ReadonlySet<PopupMessage['type']> = new Set([
'fill_credentials',
'ack_autofill_origin', 'get_settings', 'update_settings',
'get_vault_settings', 'update_vault_settings', 'get_blacklist',
'remove_blacklist', 'upload_attachment', 'download_attachment',
'remove_blacklist', 'get_active_tab_url', 'list_groups', 'upload_attachment', 'download_attachment',
'list_devices', 'add_device', 'register_this_device', 'revoke_device',
'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash',
'get_field_history',
'get_session_config', 'update_session_config',
'export_backup', 'restore_backup',
'parse_lastpass_csv', 'import_lastpass_commit',
'preview_totp_from_secret',
] as PopupMessage['type'][]);
export interface ExportBackupResponse extends Extract<Response, { ok: true }> {

View File

@@ -10,6 +10,7 @@
--bg-page: #0d1117;
--bg-pane: #161b22;
--bg-elevated: #21262d;
--bg-input: #161b22;
--border-subtle: #30363d;
/* Text */
@@ -1362,3 +1363,127 @@ textarea {
.vault-lock-screen__form input {
text-align: center;
}
/* 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;
}