feat(ext/popup): rewrite generator as inline panel with trigger

The popover (which clipped off the popup edge) becomes an inline panel
that mounts inside the form (login.ts) or settings section
(settings-vault.ts). Trigger button is  with aria-expanded toggling.
Action row varies by context: fill-field has cancel+use; configure-
defaults has only the save-default link. Escape key closes the panel.
Tests adapted to new API; 3 new tests for aria-expanded, auto-generate,
and Escape behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-24 23:30:55 -04:00
parent b03058abd9
commit ac15f060e9
5 changed files with 348 additions and 206 deletions

View File

@@ -5,7 +5,7 @@ vi.mock('../../popup', async () => {
return { sendMessage }; return { sendMessage };
}); });
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
import { sendMessage } from '../../popup'; import { sendMessage } from '../../popup';
import type { GeneratorRequest } from '../../../shared/types'; import type { GeneratorRequest } from '../../../shared/types';
@@ -16,28 +16,35 @@ const DEFAULT_REQ: GeneratorRequest = {
symbol_charset: { kind: 'safe_only' }, symbol_charset: { kind: 'safe_only' },
}; };
function setupAnchor(): HTMLElement { function setupMount(): { parent: HTMLElement; trigger: HTMLElement } {
document.body.innerHTML = '<button id="anchor">gen</button>'; document.body.innerHTML = `
return document.getElementById('anchor')!; <div id="parent">
<button id="trigger" aria-expanded="false">✨</button>
</div>
`;
return {
parent: document.getElementById('parent')!,
trigger: document.getElementById('trigger')!,
};
} }
describe('generator-popover', () => { describe('generator-panel', () => {
beforeEach(() => { beforeEach(() => {
vi.mocked(sendMessage).mockReset(); vi.mocked(sendMessage).mockReset();
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } }); vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } });
}); });
it('opens a popover with Random kind by default', async () => { it('opens a panel with Random kind by default', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
expect(document.querySelector('.generator-popover')).not.toBeNull(); expect(document.querySelector('.gen-panel')).not.toBeNull();
expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true); expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true);
}); });
it('sends generate_password on knob change (debounced)', async () => { it('sends generate_password on knob change (debounced)', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
const slider = document.querySelector('#gen-length') as HTMLInputElement; const slider = document.querySelector('#gen-length') as HTMLInputElement;
slider.value = '32'; slider.value = '32';
@@ -54,8 +61,8 @@ describe('generator-popover', () => {
}); });
it('BIP39 toggle swaps to generate_passphrase', async () => { it('BIP39 toggle swaps to generate_passphrase', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
(document.getElementById('gen-kind-bip39') as HTMLButtonElement).click(); (document.getElementById('gen-kind-bip39') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
@@ -64,13 +71,13 @@ describe('generator-popover', () => {
}); });
it('use-this-value invokes onPicked with current preview and closes', async () => { it('use-this-value invokes onPicked with current preview and closes', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
const onPicked = vi.fn(); const onPicked = vi.fn();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
(document.querySelector('#gen-use') as HTMLButtonElement).click(); (document.querySelector('#gen-use') as HTMLButtonElement).click();
expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT'); expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT');
expect(document.querySelector('.generator-popover')).toBeNull(); expect(document.querySelector('.gen-panel')).toBeNull();
}); });
it('save-as-default sends update_vault_settings with the current request', async () => { it('save-as-default sends update_vault_settings with the current request', async () => {
@@ -88,8 +95,8 @@ describe('generator-popover', () => {
if (msg.type === 'update_vault_settings') return { ok: true }; if (msg.type === 'update_vault_settings') return { ok: true };
return { ok: false, error: 'unhandled' }; return { ok: false, error: 'unhandled' };
}); });
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
(document.querySelector('#gen-save-default') as HTMLButtonElement).click(); (document.querySelector('#gen-save-default') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 50)); await new Promise((r) => setTimeout(r, 50));
@@ -102,8 +109,8 @@ describe('generator-popover', () => {
}); });
it('disables use-button when no char class selected (Random)', async () => { it('disables use-button when no char class selected (Random)', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) { for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) {
const cb = document.getElementById(id) as HTMLInputElement; const cb = document.getElementById(id) as HTMLInputElement;
@@ -114,11 +121,40 @@ describe('generator-popover', () => {
expect(useBtn.disabled).toBe(true); expect(useBtn.disabled).toBe(true);
}); });
it('closeGeneratorPopover removes the DOM + handlers', async () => { it('closeGeneratorPanel removes the DOM + handlers', async () => {
const anchor = setupAnchor(); const { parent, trigger } = setupMount();
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200)); await new Promise((r) => setTimeout(r, 200));
closeGeneratorPopover(); closeGeneratorPanel();
expect(document.querySelector('.generator-popover')).toBeNull(); expect(document.querySelector('.gen-panel')).toBeNull();
});
it('sets aria-expanded on the trigger when opened', async () => {
const { parent, trigger } = setupMount();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
expect(trigger.getAttribute('aria-expanded')).toBe('true');
closeGeneratorPanel();
expect(trigger.getAttribute('aria-expanded')).toBe('false');
});
it('auto-generates a preview on open', async () => {
const { parent, trigger } = setupMount();
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 200));
const calls = vi.mocked(sendMessage).mock.calls.filter(
([msg]) => (msg as { type: string }).type === 'generate_password',
);
expect(calls.length).toBeGreaterThan(0);
});
it('Escape key closes the panel', async () => {
const { parent, trigger } = setupMount();
openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() });
await new Promise((r) => setTimeout(r, 50));
expect(isGeneratorPanelOpen()).toBe(true);
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
expect(isGeneratorPanelOpen()).toBe(false);
expect(document.querySelector('.gen-panel')).toBeNull();
}); });
}); });

View File

@@ -1,7 +1,8 @@
/// Inline generator popover — anchored to a "gen" button, renders a /// Inline generator panel — mounts inside a parent element (form root or
/// live preview that updates as knobs change (150ms debounce). Single /// settings section). Trigger button gets aria-expanded toggled. Preview
/// underlying GeneratorRequest; kind toggle swaps between Random + /// updates live as knobs change (150ms debounce). Kind toggle swaps
/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel. /// between Random + BIP39 knob sets. Action row varies by context:
/// fill-field shows cancel+use; configure-defaults shows only save-default.
import { sendMessage } from '../popup'; import { sendMessage } from '../popup';
import type { GeneratorRequest, VaultSettings } from '../../shared/types'; import type { GeneratorRequest, VaultSettings } from '../../shared/types';
@@ -74,38 +75,52 @@ function requestFromKnobs(knobs: UiKnobs): GeneratorRequest {
}; };
} }
let activePopover: { export type GeneratorPanelContext = 'fill-field' | 'configure-defaults';
host: HTMLElement;
cleanup: () => void;
} | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
export interface OpenPopoverOpts { export interface OpenPanelOpts {
anchor: HTMLElement; parent: HTMLElement; // mount target (form root or settings section)
trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here)
initial: GeneratorRequest; initial: GeneratorRequest;
onPicked: (value: string) => void; context: GeneratorPanelContext;
onPicked?: (value: string) => void; // required when context === 'fill-field'
} }
export function openGeneratorPopover(opts: OpenPopoverOpts): void { let activePanel: {
closeGeneratorPopover(); host: HTMLElement;
trigger: HTMLElement;
cleanup: () => void;
} | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
export function openGeneratorPanel(opts: OpenPanelOpts): void {
closeGeneratorPanel();
const knobs = knobsFromRequest(opts.initial); const knobs = knobsFromRequest(opts.initial);
let currentPreview = ''; let currentPreview = '';
const host = document.createElement('div'); const host = document.createElement('div');
host.className = 'generator-popover'; host.className = 'gen-panel';
document.body.appendChild(host); opts.parent.appendChild(host);
// Position below anchor opts.trigger.setAttribute('aria-expanded', 'true');
const rect = opts.anchor.getBoundingClientRect();
host.style.top = `${rect.bottom + 6}px`;
host.style.left = `${rect.left}px`;
const render = (): void => { const escHandler = (e: KeyboardEvent): void => {
host.innerHTML = buildInnerHtml(knobs); if (e.key === 'Escape') closeGeneratorPanel();
wireInner();
refreshPreview();
}; };
document.addEventListener('keydown', escHandler);
const cleanup = (): void => {
document.removeEventListener('keydown', escHandler);
if (debounceTimer !== null) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
opts.trigger.setAttribute('aria-expanded', 'false');
host.remove();
};
activePanel = { host, trigger: opts.trigger, cleanup };
const refreshPreview = (): void => { const refreshPreview = (): void => {
if (debounceTimer !== null) clearTimeout(debounceTimer); if (debounceTimer !== null) clearTimeout(debounceTimer);
@@ -119,7 +134,7 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
if (resp.ok) { if (resp.ok) {
const d = resp.data as { password?: string; passphrase?: string }; const d = resp.data as { password?: string; passphrase?: string };
currentPreview = d.password ?? d.passphrase ?? ''; currentPreview = d.password ?? d.passphrase ?? '';
const el = host.querySelector('.gen-preview__value'); const el = host.querySelector('.preview__value');
if (el) el.textContent = currentPreview; if (el) el.textContent = currentPreview;
updateValidation(); updateValidation();
} }
@@ -132,8 +147,6 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
const noClass = knobs.kind === 'random' const noClass = knobs.kind === 'random'
&& !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols); && !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols);
useBtn.disabled = noClass; useBtn.disabled = noClass;
const note = host.querySelector('.gen-validation');
if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none';
}; };
const wireInner = (): void => { const wireInner = (): void => {
@@ -192,144 +205,126 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void {
}); });
}); });
host.querySelector('.gen-preview__regen')?.addEventListener('click', () => { host.querySelector('.preview__regen')?.addEventListener('click', () => {
refreshPreview(); refreshPreview();
}); });
host.querySelector('#gen-use')?.addEventListener('click', () => { host.querySelector('#gen-use')?.addEventListener('click', () => {
opts.onPicked(currentPreview); opts.onPicked?.(currentPreview);
closeGeneratorPopover(); closeGeneratorPanel();
});
host.querySelector('#gen-cancel')?.addEventListener('click', () => {
closeGeneratorPanel();
}); });
host.querySelector('#gen-save-default')?.addEventListener('click', async () => { host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
const getResp = await sendMessage({ type: 'get_vault_settings' }); const link = host.querySelector('#gen-save-default') as HTMLElement | null;
if (!getResp.ok) return; const settingsResp = await sendMessage({ type: 'get_vault_settings' });
const vs = (getResp.data as { settings: VaultSettings }).settings; if (!settingsResp.ok) return;
const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) }; const settings = (settingsResp.data as { settings: VaultSettings }).settings;
await sendMessage({ type: 'update_vault_settings', settings: updated }); settings.generator_defaults = requestFromKnobs(knobs);
const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null; const updateResp = await sendMessage({ type: 'update_vault_settings', settings });
if (btn) { if (!updateResp.ok) return;
const original = btn.textContent; if (link) {
btn.textContent = 'saved'; link.querySelector('.save-link__toast')?.remove();
setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 1500); const toast = document.createElement('span');
toast.className = 'save-link__toast';
toast.textContent = '✓ saved';
link.appendChild(toast);
setTimeout(() => toast.remove(), 1500);
} }
}); });
host.querySelector('#gen-reset')?.addEventListener('click', async () => {
const getResp = await sendMessage({ type: 'get_vault_settings' });
if (!getResp.ok) return;
const vs = (getResp.data as { settings: VaultSettings }).settings;
Object.assign(knobs, knobsFromRequest(vs.generator_defaults));
render();
});
host.querySelector('#gen-cancel')?.addEventListener('click', () => closeGeneratorPopover());
host.querySelector('#gen-close')?.addEventListener('click', () => closeGeneratorPopover());
}; };
const onOutsideClick = (e: MouseEvent) => { const render = (): void => {
if (!host.contains(e.target as Node) && e.target !== opts.anchor) { host.innerHTML = buildInnerHtml(knobs, opts.context);
closeGeneratorPopover(); wireInner();
} refreshPreview();
}; };
const onEsc = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeGeneratorPopover();
};
const cleanup = (): void => {
document.removeEventListener('click', onOutsideClick, true);
document.removeEventListener('keydown', onEsc);
host.remove();
};
activePopover = { host, cleanup };
setTimeout(() => {
document.addEventListener('click', onOutsideClick, true);
document.addEventListener('keydown', onEsc);
}, 0);
render(); render();
} }
export function closeGeneratorPopover(): void { export function closeGeneratorPanel(): void {
if (activePopover === null) return; if (activePanel === null) return;
if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } activePanel.cleanup();
activePopover.cleanup(); activePanel = null;
activePopover = null; }
export function isGeneratorPanelOpen(): boolean {
return activePanel !== null;
} }
// --- HTML builders --- // --- HTML builders ---
function buildInnerHtml(knobs: UiKnobs): string { function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string {
const actionRow = context === 'fill-field'
? `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>
<button class="btn" id="gen-cancel" type="button">cancel</button>
<button class="btn btn-primary" id="gen-use" type="button">use</button>`
: `<button class="save-link" id="gen-save-default" type="button">↑ save these as default</button>`;
return ` return `
<div class="gen-header"> <div class="panel-toggle">
<span class="gen-title">generate</span> <button id="gen-kind-random" type="button" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
<button type="button" id="gen-close" class="gen-close">×</button> <button id="gen-kind-bip39" type="button" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
</div>
<div class="gen-row">
<span class="gen-row__label">kind</span>
<div class="gen-toggle-group">
<button id="gen-kind-random" class="${knobs.kind === 'random' ? 'active' : ''}">Random</button>
<button id="gen-kind-bip39" class="${knobs.kind === 'bip39' ? 'active' : ''}">BIP39</button>
</div>
</div> </div>
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)} ${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
<div class="gen-preview"> <div class="preview">
<span class="gen-preview__value"></span> <span class="preview__value"></span>
<button type="button" class="gen-preview__regen" title="regenerate">↻</button> <button type="button" class="preview__regen" title="regenerate">↻</button>
</div> </div>
${knobs.kind === 'random' <div class="actions">
? `<p class="gen-validation" style="display:none;color:#ab2b20;font-size:10px;margin:4px 0 0;">pick at least one character class</p>` ${actionRow}
: ''}
<div class="gen-actions">
<button type="button" class="btn" id="gen-reset">reset to defaults</button>
<button type="button" class="btn" id="gen-save-default">save as default</button>
<button type="button" class="btn" id="gen-cancel">cancel</button>
<button type="button" class="btn btn-primary" id="gen-use">use this value</button>
</div> </div>
`; `;
} }
function buildRandomKnobs(k: UiKnobs): string { function buildRandomKnobs(k: UiKnobs): string {
return ` return `
<div class="gen-row"> <div class="knob">
<span class="gen-row__label">length</span> <span class="knob__label">length</span>
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider"> <input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="knob__slider">
<span id="gen-length-val">${k.length}</span> <span class="knob__value" id="gen-length-val">${k.length}</span>
</div> </div>
<div class="gen-check-grid"> <div class="classes">
<label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label> <label><input type="checkbox" id="gen-lower" ${k.lower ? 'checked' : ''}> lowercase</label>
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
<label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label> <label><input type="checkbox" id="gen-upper" ${k.upper ? 'checked' : ''}> uppercase</label>
<label><input type="checkbox" id="gen-digits" ${k.digits ? 'checked' : ''}> digits</label>
<label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label> <label><input type="checkbox" id="gen-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
</div> </div>
<div class="gen-row"> <details class="more">
<span class="gen-row__label">symbols</span> <summary>more ▾</summary>
<div class="gen-toggle-group"> <div class="more__advanced">
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button> <div class="knob">
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button> <span class="knob__label">symbols</span>
<div class="panel-toggle" style="flex:1;">
<button data-symbol-charset="safe_only" type="button" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
<button data-symbol-charset="extended" type="button" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
</div>
</div>
</div> </div>
</div> </details>
`; `;
} }
function buildBip39Knobs(k: UiKnobs): string { function buildBip39Knobs(k: UiKnobs): string {
const sepChip = (label: string, sep: string) => ` const sepChip = (label: string, sep: string) => `
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button> <button data-separator="${sep}" type="button" class="${k.separator === sep ? 'active' : ''}">${label}</button>
`; `;
const capChip = (label: string, val: string) => ` const capChip = (label: string, val: string) => `
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button> <button data-capitalization="${val}" type="button" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
`; `;
return ` return `
<div class="gen-row"> <div class="knob">
<span class="gen-row__label">words</span> <span class="knob__label">words</span>
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider"> <input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="knob__slider">
<span id="gen-word-count-val">${k.wordCount}</span> <span class="knob__value" id="gen-word-count-val">${k.wordCount}</span>
</div> </div>
<div class="gen-row"> <div class="knob" style="align-items:flex-start;">
<span class="gen-row__label">separator</span> <span class="knob__label">separator</span>
<div class="gen-toggle-group"> <div class="panel-toggle" style="flex:1;">
${sepChip('space', ' ')} ${sepChip('space', ' ')}
${sepChip('-', '-')} ${sepChip('-', '-')}
${sepChip('_', '_')} ${sepChip('_', '_')}
@@ -337,9 +332,9 @@ function buildBip39Knobs(k: UiKnobs): string {
${sepChip(':', ':')} ${sepChip(':', ':')}
</div> </div>
</div> </div>
<div class="gen-row"> <div class="knob" style="align-items:flex-start;">
<span class="gen-row__label">case</span> <span class="knob__label">case</span>
<div class="gen-toggle-group"> <div class="panel-toggle" style="flex:1;">
${capChip('lower', 'lower')} ${capChip('lower', 'lower')}
${capChip('upper', 'upper')} ${capChip('upper', 'upper')}
${capChip('first', 'first_of_each')} ${capChip('first', 'first_of_each')}

View File

@@ -6,7 +6,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'
import type { import type {
VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest,
} from '../../shared/types'; } from '../../shared/types';
import { openGeneratorPopover } from './generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel';
let pendingSettings: VaultSettings | null = null; let pendingSettings: VaultSettings | null = null;
let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null;
@@ -128,7 +128,7 @@ export function renderVaultSettings(app: HTMLElement): void {
<div class="settings-section"> <div class="settings-section">
<div class="settings-section__title">generator</div> <div class="settings-section__title">generator</div>
<p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p> <p class="gen-preview-line">${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}</p>
<button class="btn" id="configure-gen">configure ▾</button> <button class="gen-trigger" id="configure-gen" type="button" title="configure generator defaults" aria-expanded="false">✨</button>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -194,12 +194,18 @@ export function renderVaultSettings(app: HTMLElement): void {
}); });
document.getElementById('configure-gen')?.addEventListener('click', (e) => { document.getElementById('configure-gen')?.addEventListener('click', (e) => {
if (!pendingSettings) return; const trigger = e.currentTarget as HTMLElement;
const anchor = e.currentTarget as HTMLElement; if (isGeneratorPanelOpen()) {
openGeneratorPopover({ closeGeneratorPanel();
anchor, return;
}
const generatorSection = trigger.closest('.settings-section') as HTMLElement | null;
if (!generatorSection || pendingSettings === null) return;
openGeneratorPanel({
parent: generatorSection,
trigger,
initial: pendingSettings.generator_defaults, initial: pendingSettings.generator_defaults,
onPicked: () => {/* no-op — user is here to save as default, not pick */}, context: 'configure-defaults',
}); });
}); });

View File

@@ -14,7 +14,7 @@ import {
renderSectionsEditor, renderSectionsEditor,
wireSectionsEditor, wireSectionsEditor,
} from '../fields'; } from '../fields';
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel'; import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel';
/// Called by the dispatcher before each render. Stops any in-flight /// Called by the dispatcher before each render. Stops any in-flight
/// tickers / intervals / listeners the previous view may have attached. /// tickers / intervals / listeners the previous view may have attached.
@@ -29,7 +29,7 @@ export function teardown(): void {
activeFormEscHandler = null; activeFormEscHandler = null;
} }
sectionsExpanded = false; sectionsExpanded = false;
closeGeneratorPopover(); closeGeneratorPanel();
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@@ -240,7 +240,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
<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"> <div class="inline-row">
<input id="f-password" type="password" value="${escapeHtml(password)}"> <input id="f-password" type="password" value="${escapeHtml(password)}">
<button class="btn" id="gen-btn" title="generate">gen</button> <button class="gen-trigger" id="gen-btn" type="button" title="generate password" aria-expanded="false">✨</button>
</div></div> </div></div>
<div class="form-group"><label class="label" for="f-totp">totp secret (base32)</label> <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> <input id="f-totp" type="text" value="${escapeHtml(totpStr)}" placeholder="JBSWY3DPEHPK3PXP"></div>
@@ -266,11 +266,19 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
wireSectionsEditor(app, sectionsDraft, rerender); wireSectionsEditor(app, sectionsDraft, rerender);
document.getElementById('gen-btn')?.addEventListener('click', (e) => { document.getElementById('gen-btn')?.addEventListener('click', (e) => {
const anchor = e.currentTarget as HTMLElement; const trigger = e.currentTarget as HTMLElement;
if (isGeneratorPanelOpen()) {
closeGeneratorPanel();
return;
}
const passwordRow = trigger.closest('.form-group') as HTMLElement | null;
if (!passwordRow) return;
const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST; const initial = getState().generatorDefaults ?? DEFAULT_PASSWORD_REQUEST;
openGeneratorPopover({ openGeneratorPanel({
anchor, parent: passwordRow, // panel mounts inside the password form-group
trigger,
initial, initial,
context: 'fill-field',
onPicked: (value) => { onPicked: (value) => {
const pw = document.getElementById('f-password') as HTMLInputElement | null; const pw = document.getElementById('f-password') as HTMLInputElement | null;
if (pw) { pw.value = value; pw.type = 'text'; } if (pw) { pw.value = value; pw.type = 'text'; }

View File

@@ -595,66 +595,163 @@ textarea {
} }
.disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; } .disclosure__body .add-section:hover { border-color: #484f58; color: #c9d1d9; }
/* --- generator popover (β₂ slice 4) --- */ /* --- generator panel (gen-UX redesign) --- */
.generator-popover {
position: absolute; z-index: 9999999; .gen-trigger {
background: #161b22; border: 1px solid #30363d; border-radius: 6px; background: #7c5719;
box-shadow: 0 4px 16px rgba(0,0,0,0.5); color: #fff3cf;
padding: 14px; min-width: 300px; max-width: 340px; border: none;
font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9; border-radius: 4px;
padding: 0 12px;
font-size: 16px;
cursor: pointer;
line-height: 1;
min-width: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
} }
.generator-popover .gen-header { .gen-trigger:hover { background: #aa812a; }
display: flex; justify-content: space-between; align-items: center; .gen-trigger[aria-expanded="true"] { background: #aa812a; }
.gen-panel {
background: #161b22;
border: 1px solid #aa812a;
border-radius: 6px;
padding: 11px;
margin: 6px 0;
font-size: 11px;
color: #c9d1d9;
}
.gen-panel .panel-toggle {
display: flex;
gap: 4px;
background: #21262d;
border-radius: 4px;
padding: 2px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; } .gen-panel .panel-toggle button {
.generator-popover .gen-close { flex: 1;
background: transparent; border: 0; color: #8b949e; cursor: pointer; background: transparent;
font-size: 14px; padding: 2px 6px; border: 0;
color: #8b949e;
padding: 5px;
font-size: 11px;
cursor: pointer;
border-radius: 3px;
font-weight: 600;
} }
.generator-popover .gen-row { .gen-panel .panel-toggle button.active {
display: flex; align-items: center; gap: 8px; margin: 6px 0; background: #aa812a;
color: #fff3cf;
} }
.generator-popover .gen-row__label { .gen-panel .knob {
color: #8b949e; width: 70px; flex-shrink: 0; display: flex;
font-size: 10px; text-transform: lowercase; align-items: center;
gap: 8px;
margin: 6px 0;
} }
.generator-popover .gen-toggle-group { .gen-panel .knob__label {
display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden; color: #8b949e;
width: 56px;
flex-shrink: 0;
font-size: 10px;
} }
.generator-popover .gen-toggle-group button { .gen-panel .knob__slider { flex: 1; }
background: transparent; border: 0; color: #8b949e; .gen-panel .knob__value {
padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px; font-family: ui-monospace, monospace;
min-width: 24px;
text-align: right;
color: #c9d1d9;
} }
.generator-popover .gen-toggle-group button.active { background: #7c5719; color: #fff; } .gen-panel .classes {
.generator-popover .gen-slider { flex: 1; } display: flex;
.generator-popover .gen-slider + span { gap: 8px;
color: #c9d1d9; font-variant-numeric: tabular-nums; font-size: 10px;
font-family: monospace; min-width: 24px; text-align: right; margin: 6px 0;
flex-wrap: wrap;
color: #8b949e;
} }
.generator-popover .gen-check-grid { .gen-panel .classes label {
display: grid; grid-template-columns: 1fr 1fr; display: flex;
gap: 4px 16px; margin: 6px 0; font-size: 11px; align-items: center;
gap: 3px;
user-select: none;
cursor: pointer;
} }
.generator-popover .gen-check-grid label { .gen-panel .preview {
display: flex; align-items: center; gap: 6px; background: #0d1117;
border: 1px solid #30363d;
border-radius: 4px;
padding: 8px 10px;
margin-top: 8px;
display: flex;
align-items: center;
gap: 8px;
} }
.generator-popover .gen-preview { .gen-panel .preview__value {
margin: 10px 0 8px; padding: 8px 10px; flex: 1;
background: #0d1117; border: 1px solid #30363d; border-radius: 4px; color: #f1cf6e;
font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9; font-family: ui-monospace, monospace;
display: flex; justify-content: space-between; align-items: center; gap: 8px; font-size: 12px;
word-break: break-all; overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.generator-popover .gen-preview__regen { .gen-panel .preview__regen {
flex-shrink: 0; background: transparent; border: 0; background: transparent;
color: #d2ab43; cursor: pointer; font-size: 12px; border: 0;
color: #8b949e;
cursor: pointer;
padding: 0 4px;
font-size: 14px;
} }
.generator-popover .gen-actions { .gen-panel .more {
display: grid; grid-template-columns: 1fr 1fr; color: #8b949e;
gap: 6px; margin-top: 10px; font-size: 10px;
margin-top: 6px;
cursor: pointer;
user-select: none;
padding: 2px 0;
} }
.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; } .gen-panel .more summary {
list-style: none;
outline: none;
}
.gen-panel .more summary::-webkit-details-marker { display: none; }
.gen-panel .more:hover { color: #d2ab43; }
.gen-panel .more__advanced { margin-top: 6px; }
.gen-panel .actions {
display: flex;
gap: 6px;
margin-top: 10px;
align-items: center;
}
.gen-panel .actions .save-link {
flex: 1;
background: transparent;
border: 0;
color: #8b949e;
cursor: pointer;
font-size: 10px;
text-align: left;
padding: 4px 0;
text-decoration: underline;
text-decoration-color: #30363d;
text-underline-offset: 2px;
}
.gen-panel .actions .save-link:hover {
color: #d2ab43;
text-decoration-color: #d2ab43;
}
.gen-panel .actions .save-link__toast {
color: #3fb950;
margin-left: 6px;
font-size: 10px;
}
/* keep .gen-preview-line — it's the summary-text in vault settings, separate from panel */
/* --- settings-vault screen (β₂ slice 5) --- */ /* --- settings-vault screen (β₂ slice 5) --- */
.settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } .settings-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }