/// Inline generator panel — mounts inside a parent element (form root or /// settings section). Trigger button gets aria-expanded toggled. Preview /// updates live as knobs change (150ms debounce). Kind toggle swaps /// between Random + BIP39 knob sets. Action row varies by context: /// fill-field shows cancel+use; configure-defaults shows only save-default. import { sendMessage } from '../../shared/state'; import type { GeneratorRequest, VaultSettings } from '../../shared/types'; import { colorizePassword } from '../../shared/password-coloring'; interface UiKnobs { kind: 'random' | 'bip39'; // Random length: number; lower: boolean; upper: boolean; digits: boolean; symbols: boolean; symbolCharset: 'safe_only' | 'extended' | 'custom'; customSymbols: string; // BIP39 wordCount: number; separator: string; capitalization: 'lower' | 'upper' | 'first_of_each' | 'title' | 'mixed'; } function knobsFromRequest(req: GeneratorRequest): UiKnobs { const defaults: UiKnobs = { kind: 'random', length: 20, lower: true, upper: true, digits: true, symbols: true, symbolCharset: 'safe_only', customSymbols: '', wordCount: 5, separator: ' ', capitalization: 'lower', }; if (req.kind === 'random') { return { ...defaults, kind: 'random', length: req.length, lower: req.classes.lower, upper: req.classes.upper, digits: req.classes.digits, symbols: req.classes.symbols, symbolCharset: req.symbol_charset.kind, customSymbols: req.symbol_charset.kind === 'custom' ? req.symbol_charset.value : '', }; } return { ...defaults, kind: 'bip39', wordCount: req.word_count, separator: req.separator, capitalization: req.capitalization, }; } function requestFromKnobs(knobs: UiKnobs): GeneratorRequest { if (knobs.kind === 'random') { return { kind: 'random', length: knobs.length, classes: { lower: knobs.lower, upper: knobs.upper, digits: knobs.digits, symbols: knobs.symbols, }, symbol_charset: knobs.symbolCharset === 'safe_only' ? { kind: 'safe_only' } : knobs.symbolCharset === 'extended' ? { kind: 'extended' } : { kind: 'custom', value: knobs.customSymbols }, }; } return { kind: 'bip39', word_count: knobs.wordCount, separator: knobs.separator, capitalization: knobs.capitalization, }; } export type GeneratorPanelContext = 'fill-field' | 'configure-defaults'; export interface OpenPanelOpts { parent: HTMLElement; // mount target (form root or settings section) trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here) initial: GeneratorRequest; context: GeneratorPanelContext; onPicked?: (value: string) => void; // required when context === 'fill-field' } let activePanel: { host: HTMLElement; trigger: HTMLElement; cleanup: () => void; } | null = null; let debounceTimer: ReturnType | null = null; export function openGeneratorPanel(opts: OpenPanelOpts): void { closeGeneratorPanel(); const knobs = knobsFromRequest(opts.initial); let currentPreview = ''; const host = document.createElement('div'); host.className = 'gen-panel'; opts.parent.appendChild(host); opts.trigger.setAttribute('aria-expanded', 'true'); const escHandler = (e: KeyboardEvent): void => { if (e.key === 'Escape') { e.stopPropagation(); closeGeneratorPanel(); } }; 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 => { if (debounceTimer !== null) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { debounceTimer = null; const request = requestFromKnobs(knobs); const msg = knobs.kind === 'random' ? { type: 'generate_password' as const, request } : { type: 'generate_passphrase' as const, request }; const resp = await sendMessage(msg); if (resp.ok) { const d = resp.data as { password?: string; passphrase?: string }; currentPreview = d.password ?? d.passphrase ?? ''; const el = host.querySelector('.preview__value'); if (el) { el.textContent = ''; el.appendChild(colorizePassword(currentPreview)); } updateValidation(); } }, 150); }; const updateValidation = (): void => { const useBtn = host.querySelector('#gen-use') as HTMLButtonElement | null; if (!useBtn) return; const noClass = knobs.kind === 'random' && !(knobs.lower || knobs.upper || knobs.digits || knobs.symbols); useBtn.disabled = noClass; }; const wireInner = (): void => { host.querySelector('#gen-kind-random')?.addEventListener('click', () => { knobs.kind = 'random'; render(); }); host.querySelector('#gen-kind-bip39')?.addEventListener('click', () => { knobs.kind = 'bip39'; render(); }); host.querySelector('#gen-length')?.addEventListener('input', (e) => { knobs.length = Number((e.target as HTMLInputElement).value); const out = host.querySelector('#gen-length-val'); if (out) out.textContent = String(knobs.length); refreshPreview(); }); for (const { id, key } of [ { id: 'gen-lower', key: 'lower' as const }, { id: 'gen-upper', key: 'upper' as const }, { id: 'gen-digits', key: 'digits' as const }, { id: 'gen-symbols', key: 'symbols' as const }, ]) { host.querySelector(`#${id}`)?.addEventListener('change', (e) => { knobs[key] = (e.target as HTMLInputElement).checked; updateValidation(); refreshPreview(); }); } host.querySelectorAll('[data-symbol-charset]').forEach((btn) => { btn.addEventListener('click', () => { knobs.symbolCharset = btn.dataset.symbolCharset as UiKnobs['symbolCharset']; render(); }); }); host.querySelector('#gen-word-count')?.addEventListener('input', (e) => { knobs.wordCount = Number((e.target as HTMLInputElement).value); const out = host.querySelector('#gen-word-count-val'); if (out) out.textContent = String(knobs.wordCount); refreshPreview(); }); host.querySelectorAll('[data-separator]').forEach((btn) => { btn.addEventListener('click', () => { knobs.separator = btn.dataset.separator ?? ' '; render(); }); }); host.querySelectorAll('[data-capitalization]').forEach((btn) => { btn.addEventListener('click', () => { knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization']; render(); }); }); host.querySelector('.preview__regen')?.addEventListener('click', () => { refreshPreview(); }); host.querySelector('#gen-use')?.addEventListener('click', () => { opts.onPicked?.(currentPreview); closeGeneratorPanel(); }); host.querySelector('#gen-cancel')?.addEventListener('click', () => { closeGeneratorPanel(); }); host.querySelector('#gen-save-default')?.addEventListener('click', async () => { const link = host.querySelector('#gen-save-default') as HTMLElement | null; const settingsResp = await sendMessage({ type: 'get_vault_settings' }); if (!settingsResp.ok) return; const settings = (settingsResp.data as { settings: VaultSettings }).settings; settings.generator_defaults = requestFromKnobs(knobs); const updateResp = await sendMessage({ type: 'update_vault_settings', settings }); if (!updateResp.ok) return; if (link) { link.querySelector('.save-link__toast')?.remove(); const toast = document.createElement('span'); toast.className = 'save-link__toast'; toast.textContent = '✓ saved'; link.appendChild(toast); setTimeout(() => toast.remove(), 1500); } }); }; const render = (): void => { host.innerHTML = buildInnerHtml(knobs, opts.context); wireInner(); refreshPreview(); }; render(); } export function closeGeneratorPanel(): void { if (activePanel === null) return; activePanel.cleanup(); activePanel = null; } export function isGeneratorPanelOpen(): boolean { return activePanel !== null; } // --- HTML builders --- function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string { const actionRow = context === 'fill-field' ? ` ` : ``; return `
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
${actionRow}
`; } function buildRandomKnobs(k: UiKnobs): string { return `
length ${k.length}
more ▾
symbols
`; } function buildBip39Knobs(k: UiKnobs): string { const sepChip = (label: string, sep: string) => ` `; const capChip = (label: string, val: string) => ` `; return `
words ${k.wordCount}
separator
${sepChip('space', ' ')} ${sepChip('-', '-')} ${sepChip('_', '_')} ${sepChip('.', '.')} ${sepChip(':', ':')}
case
${capChip('lower', 'lower')} ${capChip('upper', 'upper')} ${capChip('first', 'first_of_each')} ${capChip('title', 'title')}
`; }