feat(ext/popup): generator-popover component (Random + BIP39)
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../popup', async () => {
|
||||||
|
const sendMessage = vi.fn();
|
||||||
|
return { sendMessage };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { openGeneratorPopover, closeGeneratorPopover } from '../generator-popover';
|
||||||
|
import { sendMessage } from '../../popup';
|
||||||
|
import type { GeneratorRequest } from '../../../shared/types';
|
||||||
|
|
||||||
|
const DEFAULT_REQ: GeneratorRequest = {
|
||||||
|
kind: 'random',
|
||||||
|
length: 20,
|
||||||
|
classes: { lower: true, upper: true, digits: true, symbols: true },
|
||||||
|
symbol_charset: { kind: 'safe_only' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupAnchor(): HTMLElement {
|
||||||
|
document.body.innerHTML = '<button id="anchor">gen</button>';
|
||||||
|
return document.getElementById('anchor')!;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('generator-popover', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(sendMessage).mockReset();
|
||||||
|
vi.mocked(sendMessage).mockResolvedValue({ ok: true, data: { password: 'Kj7%pW@2xNq!8rMvT' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens a popover with Random kind by default', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
expect(document.querySelector('.generator-popover')).not.toBeNull();
|
||||||
|
expect(document.querySelector('#gen-kind-random')?.classList.contains('active')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends generate_password on knob change (debounced)', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const slider = document.querySelector('#gen-length') as HTMLInputElement;
|
||||||
|
slider.value = '32';
|
||||||
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls.filter(
|
||||||
|
([msg]) => (msg as { type: string }).type === 'generate_password',
|
||||||
|
);
|
||||||
|
const latest = calls[calls.length - 1]![0] as { request: GeneratorRequest };
|
||||||
|
expect(latest.request.kind).toBe('random');
|
||||||
|
if (latest.request.kind === 'random') {
|
||||||
|
expect(latest.request.length).toBe(32);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BIP39 toggle swaps to generate_passphrase', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.getElementById('gen-kind-bip39') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
const calls = vi.mocked(sendMessage).mock.calls;
|
||||||
|
expect(calls.some(([msg]) => (msg as { type: string }).type === 'generate_passphrase')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('use-this-value invokes onPicked with current preview and closes', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
const onPicked = vi.fn();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-use') as HTMLButtonElement).click();
|
||||||
|
expect(onPicked).toHaveBeenCalledWith('Kj7%pW@2xNq!8rMvT');
|
||||||
|
expect(document.querySelector('.generator-popover')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('save-as-default sends update_vault_settings with the current request', async () => {
|
||||||
|
vi.mocked(sendMessage).mockImplementation(async (msg: any) => {
|
||||||
|
if (msg.type === 'generate_password') return { ok: true, data: { password: 'abc' } };
|
||||||
|
if (msg.type === 'get_vault_settings') {
|
||||||
|
return { ok: true, data: { settings: {
|
||||||
|
trash_retention: { kind: 'days', value: 30 },
|
||||||
|
field_history_retention: { kind: 'forever' },
|
||||||
|
generator_defaults: DEFAULT_REQ,
|
||||||
|
attachment_caps: {},
|
||||||
|
autofill_origin_acks: {},
|
||||||
|
} } };
|
||||||
|
}
|
||||||
|
if (msg.type === 'update_vault_settings') return { ok: true };
|
||||||
|
return { ok: false, error: 'unhandled' };
|
||||||
|
});
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
(document.querySelector('#gen-save-default') as HTMLButtonElement).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
const updateCall = vi.mocked(sendMessage).mock.calls.find(
|
||||||
|
([m]) => (m as any).type === 'update_vault_settings',
|
||||||
|
);
|
||||||
|
expect(updateCall).toBeDefined();
|
||||||
|
const msg = updateCall![0] as { settings: { generator_defaults: GeneratorRequest } };
|
||||||
|
expect(msg.settings.generator_defaults.kind).toBe('random');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables use-button when no char class selected (Random)', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
for (const id of ['gen-lower', 'gen-upper', 'gen-digits', 'gen-symbols']) {
|
||||||
|
const cb = document.getElementById(id) as HTMLInputElement;
|
||||||
|
cb.checked = false;
|
||||||
|
cb.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
const useBtn = document.querySelector('#gen-use') as HTMLButtonElement;
|
||||||
|
expect(useBtn.disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closeGeneratorPopover removes the DOM + handlers', async () => {
|
||||||
|
const anchor = setupAnchor();
|
||||||
|
openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() });
|
||||||
|
await new Promise((r) => setTimeout(r, 200));
|
||||||
|
closeGeneratorPopover();
|
||||||
|
expect(document.querySelector('.generator-popover')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
350
extension/src/popup/components/generator-popover.ts
Normal file
350
extension/src/popup/components/generator-popover.ts
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
/// Inline generator popover — anchored to a "gen" button, renders a
|
||||||
|
/// live preview that updates as knobs change (150ms debounce). Single
|
||||||
|
/// underlying GeneratorRequest; kind toggle swaps between Random +
|
||||||
|
/// BIP39 knob sets. Actions: use / save-as-default / reset / cancel.
|
||||||
|
|
||||||
|
import { sendMessage } from '../popup';
|
||||||
|
import type { GeneratorRequest, VaultSettings } from '../../shared/types';
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let activePopover: {
|
||||||
|
host: HTMLElement;
|
||||||
|
cleanup: () => void;
|
||||||
|
} | null = null;
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export interface OpenPopoverOpts {
|
||||||
|
anchor: HTMLElement;
|
||||||
|
initial: GeneratorRequest;
|
||||||
|
onPicked: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGeneratorPopover(opts: OpenPopoverOpts): void {
|
||||||
|
closeGeneratorPopover();
|
||||||
|
|
||||||
|
const knobs = knobsFromRequest(opts.initial);
|
||||||
|
let currentPreview = '';
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.className = 'generator-popover';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
// Position below anchor
|
||||||
|
const rect = opts.anchor.getBoundingClientRect();
|
||||||
|
host.style.top = `${rect.bottom + 6}px`;
|
||||||
|
host.style.left = `${rect.left}px`;
|
||||||
|
|
||||||
|
const render = (): void => {
|
||||||
|
host.innerHTML = buildInnerHtml(knobs);
|
||||||
|
wireInner();
|
||||||
|
refreshPreview();
|
||||||
|
};
|
||||||
|
|
||||||
|
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('.gen-preview__value');
|
||||||
|
if (el) el.textContent = 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 note = host.querySelector('.gen-validation');
|
||||||
|
if (note) (note as HTMLElement).style.display = noClass ? 'block' : 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-separator]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.separator = btn.dataset.separator ?? ' ';
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelectorAll<HTMLButtonElement>('[data-capitalization]').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
knobs.capitalization = btn.dataset.capitalization as UiKnobs['capitalization'];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('.gen-preview__regen')?.addEventListener('click', () => {
|
||||||
|
refreshPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-use')?.addEventListener('click', () => {
|
||||||
|
opts.onPicked(currentPreview);
|
||||||
|
closeGeneratorPopover();
|
||||||
|
});
|
||||||
|
|
||||||
|
host.querySelector('#gen-save-default')?.addEventListener('click', async () => {
|
||||||
|
const getResp = await sendMessage({ type: 'get_vault_settings' });
|
||||||
|
if (!getResp.ok) return;
|
||||||
|
const vs = (getResp.data as { settings: VaultSettings }).settings;
|
||||||
|
const updated: VaultSettings = { ...vs, generator_defaults: requestFromKnobs(knobs) };
|
||||||
|
await sendMessage({ type: 'update_vault_settings', settings: updated });
|
||||||
|
const btn = host.querySelector('#gen-save-default') as HTMLButtonElement | null;
|
||||||
|
if (btn) {
|
||||||
|
const original = btn.textContent;
|
||||||
|
btn.textContent = 'saved';
|
||||||
|
setTimeout(() => { if (btn.textContent === 'saved') btn.textContent = original; }, 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) => {
|
||||||
|
if (!host.contains(e.target as Node) && e.target !== opts.anchor) {
|
||||||
|
closeGeneratorPopover();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeGeneratorPopover(): void {
|
||||||
|
if (activePopover === null) return;
|
||||||
|
if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; }
|
||||||
|
activePopover.cleanup();
|
||||||
|
activePopover = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- HTML builders ---
|
||||||
|
|
||||||
|
function buildInnerHtml(knobs: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="gen-header">
|
||||||
|
<span class="gen-title">generate</span>
|
||||||
|
<button type="button" id="gen-close" class="gen-close">×</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>
|
||||||
|
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)}
|
||||||
|
<div class="gen-preview">
|
||||||
|
<span class="gen-preview__value"></span>
|
||||||
|
<button type="button" class="gen-preview__regen" title="regenerate">↻</button>
|
||||||
|
</div>
|
||||||
|
${knobs.kind === 'random'
|
||||||
|
? `<p class="gen-validation" style="display:none;color:#f85149;font-size:10px;margin:4px 0 0;">pick at least one character class</p>`
|
||||||
|
: ''}
|
||||||
|
<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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRandomKnobs(k: UiKnobs): string {
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">length</span>
|
||||||
|
<input type="range" id="gen-length" min="8" max="64" value="${k.length}" class="gen-slider">
|
||||||
|
<span id="gen-length-val">${k.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-check-grid">
|
||||||
|
<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-symbols" ${k.symbols ? 'checked' : ''}> symbols</label>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">symbols</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
<button data-symbol-charset="safe_only" class="${k.symbolCharset === 'safe_only' ? 'active' : ''}">safe</button>
|
||||||
|
<button data-symbol-charset="extended" class="${k.symbolCharset === 'extended' ? 'active' : ''}">extended</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBip39Knobs(k: UiKnobs): string {
|
||||||
|
const sepChip = (label: string, sep: string) => `
|
||||||
|
<button data-separator="${sep}" class="${k.separator === sep ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
const capChip = (label: string, val: string) => `
|
||||||
|
<button data-capitalization="${val}" class="${k.capitalization === val ? 'active' : ''}">${label}</button>
|
||||||
|
`;
|
||||||
|
return `
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">words</span>
|
||||||
|
<input type="range" id="gen-word-count" min="3" max="12" value="${k.wordCount}" class="gen-slider">
|
||||||
|
<span id="gen-word-count-val">${k.wordCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">separator</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${sepChip('space', ' ')}
|
||||||
|
${sepChip('-', '-')}
|
||||||
|
${sepChip('_', '_')}
|
||||||
|
${sepChip('.', '.')}
|
||||||
|
${sepChip(':', ':')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="gen-row">
|
||||||
|
<span class="gen-row__label">case</span>
|
||||||
|
<div class="gen-toggle-group">
|
||||||
|
${capChip('lower', 'lower')}
|
||||||
|
${capChip('upper', 'upper')}
|
||||||
|
${capChip('first', 'first_of_each')}
|
||||||
|
${capChip('title', 'title')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -588,3 +588,64 @@ textarea {
|
|||||||
width: 100%; font-size: 11px; font-family: inherit;
|
width: 100%; font-size: 11px; font-family: inherit;
|
||||||
}
|
}
|
||||||
.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-popover {
|
||||||
|
position: absolute; z-index: 9999999;
|
||||||
|
background: #161b22; border: 1px solid #30363d; border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
||||||
|
padding: 14px; min-width: 300px; max-width: 340px;
|
||||||
|
font-size: 11px; font-family: system-ui, sans-serif; color: #c9d1d9;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-header {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-title { font-size: 11px; font-weight: 600; color: #8b949e; text-transform: lowercase; letter-spacing: 0.08em; }
|
||||||
|
.generator-popover .gen-close {
|
||||||
|
background: transparent; border: 0; color: #8b949e; cursor: pointer;
|
||||||
|
font-size: 14px; padding: 2px 6px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-row {
|
||||||
|
display: flex; align-items: center; gap: 8px; margin: 6px 0;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-row__label {
|
||||||
|
color: #8b949e; width: 70px; flex-shrink: 0;
|
||||||
|
font-size: 10px; text-transform: lowercase;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group {
|
||||||
|
display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group button {
|
||||||
|
background: transparent; border: 0; color: #8b949e;
|
||||||
|
padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-toggle-group button.active { background: #1f6feb; color: #fff; }
|
||||||
|
.generator-popover .gen-slider { flex: 1; }
|
||||||
|
.generator-popover .gen-slider + span {
|
||||||
|
color: #c9d1d9; font-variant-numeric: tabular-nums;
|
||||||
|
font-family: monospace; min-width: 24px; text-align: right;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-check-grid {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px 16px; margin: 6px 0; font-size: 11px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-check-grid label {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-preview {
|
||||||
|
margin: 10px 0 8px; padding: 8px 10px;
|
||||||
|
background: #0d1117; border: 1px solid #30363d; border-radius: 4px;
|
||||||
|
font-family: "SF Mono", "JetBrains Mono", monospace; color: #c9d1d9;
|
||||||
|
display: flex; justify-content: space-between; align-items: center; gap: 8px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-preview__regen {
|
||||||
|
flex-shrink: 0; background: transparent; border: 0;
|
||||||
|
color: #58a6ff; cursor: pointer; font-size: 12px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-actions {
|
||||||
|
display: grid; grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 6px; margin-top: 10px;
|
||||||
|
}
|
||||||
|
.generator-popover .gen-actions .btn { font-size: 11px; padding: 5px 10px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user