From ac15f060e909e8634bcb1c90008190569ddf175a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Fri, 24 Apr 2026 23:30:55 -0400 Subject: [PATCH] =?UTF-8?q?feat(ext/popup):=20rewrite=20generator=20as=20i?= =?UTF-8?q?nline=20panel=20with=20=E2=9C=A8=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/generator-panel.test.ts | 86 +++++-- .../src/popup/components/generator-panel.ts | 239 +++++++++--------- .../src/popup/components/settings-vault.ts | 20 +- extension/src/popup/components/types/login.ts | 20 +- extension/src/popup/styles.css | 189 ++++++++++---- 5 files changed, 348 insertions(+), 206 deletions(-) diff --git a/extension/src/popup/components/__tests__/generator-panel.test.ts b/extension/src/popup/components/__tests__/generator-panel.test.ts index 5ba67b4..ab2c7a4 100644 --- a/extension/src/popup/components/__tests__/generator-panel.test.ts +++ b/extension/src/popup/components/__tests__/generator-panel.test.ts @@ -5,7 +5,7 @@ vi.mock('../../popup', async () => { return { sendMessage }; }); -import { openGeneratorPopover, closeGeneratorPopover } from '../generator-panel'; +import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from '../generator-panel'; import { sendMessage } from '../../popup'; import type { GeneratorRequest } from '../../../shared/types'; @@ -16,28 +16,35 @@ const DEFAULT_REQ: GeneratorRequest = { symbol_charset: { kind: 'safe_only' }, }; -function setupAnchor(): HTMLElement { - document.body.innerHTML = ''; - return document.getElementById('anchor')!; +function setupMount(): { parent: HTMLElement; trigger: HTMLElement } { + document.body.innerHTML = ` +
+ +
+ `; + return { + parent: document.getElementById('parent')!, + trigger: document.getElementById('trigger')!, + }; } -describe('generator-popover', () => { +describe('generator-panel', () => { 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() }); + it('opens a panel with Random kind by default', async () => { + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() }); 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); }); it('sends generate_password on knob change (debounced)', async () => { - const anchor = setupAnchor(); - openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() }); await new Promise((r) => setTimeout(r, 200)); const slider = document.querySelector('#gen-length') as HTMLInputElement; slider.value = '32'; @@ -54,8 +61,8 @@ describe('generator-popover', () => { }); it('BIP39 toggle swaps to generate_passphrase', async () => { - const anchor = setupAnchor(); - openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', 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)); @@ -64,13 +71,13 @@ describe('generator-popover', () => { }); it('use-this-value invokes onPicked with current preview and closes', async () => { - const anchor = setupAnchor(); + const { parent, trigger } = setupMount(); 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)); (document.querySelector('#gen-use') as HTMLButtonElement).click(); 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 () => { @@ -88,8 +95,8 @@ describe('generator-popover', () => { 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() }); + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', 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)); @@ -102,8 +109,8 @@ describe('generator-popover', () => { }); it('disables use-button when no char class selected (Random)', async () => { - const anchor = setupAnchor(); - openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', 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; @@ -114,11 +121,40 @@ describe('generator-popover', () => { expect(useBtn.disabled).toBe(true); }); - it('closeGeneratorPopover removes the DOM + handlers', async () => { - const anchor = setupAnchor(); - openGeneratorPopover({ anchor, initial: DEFAULT_REQ, onPicked: vi.fn() }); + it('closeGeneratorPanel removes the DOM + handlers', async () => { + const { parent, trigger } = setupMount(); + openGeneratorPanel({ parent, trigger, initial: DEFAULT_REQ, context: 'fill-field', onPicked: vi.fn() }); await new Promise((r) => setTimeout(r, 200)); - closeGeneratorPopover(); - expect(document.querySelector('.generator-popover')).toBeNull(); + closeGeneratorPanel(); + 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(); }); }); diff --git a/extension/src/popup/components/generator-panel.ts b/extension/src/popup/components/generator-panel.ts index 5dbc35e..51f716e 100644 --- a/extension/src/popup/components/generator-panel.ts +++ b/extension/src/popup/components/generator-panel.ts @@ -1,7 +1,8 @@ -/// 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. +/// 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 '../popup'; import type { GeneratorRequest, VaultSettings } from '../../shared/types'; @@ -74,38 +75,52 @@ function requestFromKnobs(knobs: UiKnobs): GeneratorRequest { }; } -let activePopover: { - host: HTMLElement; - cleanup: () => void; -} | null = null; -let debounceTimer: ReturnType | null = null; +export type GeneratorPanelContext = 'fill-field' | 'configure-defaults'; -export interface OpenPopoverOpts { - anchor: HTMLElement; +export interface OpenPanelOpts { + parent: HTMLElement; // mount target (form root or settings section) + trigger: HTMLElement; // ✨ button (aria-expanded gets toggled here) initial: GeneratorRequest; - onPicked: (value: string) => void; + context: GeneratorPanelContext; + onPicked?: (value: string) => void; // required when context === 'fill-field' } -export function openGeneratorPopover(opts: OpenPopoverOpts): void { - closeGeneratorPopover(); +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 = 'generator-popover'; - document.body.appendChild(host); + host.className = 'gen-panel'; + opts.parent.appendChild(host); - // Position below anchor - const rect = opts.anchor.getBoundingClientRect(); - host.style.top = `${rect.bottom + 6}px`; - host.style.left = `${rect.left}px`; + opts.trigger.setAttribute('aria-expanded', 'true'); - const render = (): void => { - host.innerHTML = buildInnerHtml(knobs); - wireInner(); - refreshPreview(); + const escHandler = (e: KeyboardEvent): void => { + if (e.key === 'Escape') 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); @@ -119,7 +134,7 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void { if (resp.ok) { const d = resp.data as { password?: string; passphrase?: string }; currentPreview = d.password ?? d.passphrase ?? ''; - const el = host.querySelector('.gen-preview__value'); + const el = host.querySelector('.preview__value'); if (el) el.textContent = currentPreview; updateValidation(); } @@ -132,8 +147,6 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void { 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 => { @@ -192,144 +205,126 @@ export function openGeneratorPopover(opts: OpenPopoverOpts): void { }); }); - host.querySelector('.gen-preview__regen')?.addEventListener('click', () => { + host.querySelector('.preview__regen')?.addEventListener('click', () => { refreshPreview(); }); host.querySelector('#gen-use')?.addEventListener('click', () => { - opts.onPicked(currentPreview); - closeGeneratorPopover(); + opts.onPicked?.(currentPreview); + closeGeneratorPanel(); + }); + + host.querySelector('#gen-cancel')?.addEventListener('click', () => { + closeGeneratorPanel(); }); 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); + 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); } }); - - 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 render = (): void => { + host.innerHTML = buildInnerHtml(knobs, opts.context); + 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(); } -export function closeGeneratorPopover(): void { - if (activePopover === null) return; - if (debounceTimer !== null) { clearTimeout(debounceTimer); debounceTimer = null; } - activePopover.cleanup(); - activePopover = null; +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): string { +function buildInnerHtml(knobs: UiKnobs, context: GeneratorPanelContext): string { + const actionRow = context === 'fill-field' + ? ` + + ` + : ``; + return ` -
- generate - -
-
- kind -
- - -
+
+ +
${knobs.kind === 'random' ? buildRandomKnobs(knobs) : buildBip39Knobs(knobs)} -
- - +
+ +
- ${knobs.kind === 'random' - ? `` - : ''} -
- - - - +
+ ${actionRow}
`; } function buildRandomKnobs(k: UiKnobs): string { return ` -
- length - - ${k.length} +
+ length + + ${k.length}
-
+
- +
-
- symbols -
- - +
+ more ▾ +
+
+ symbols +
+ + +
+
-
+ `; } function buildBip39Knobs(k: UiKnobs): string { const sepChip = (label: string, sep: string) => ` - + `; const capChip = (label: string, val: string) => ` - + `; return ` -
- words - - ${k.wordCount} +
+ words + + ${k.wordCount}
-
- separator -
+
+ separator +
${sepChip('space', ' ')} ${sepChip('-', '-')} ${sepChip('_', '_')} @@ -337,9 +332,9 @@ function buildBip39Knobs(k: UiKnobs): string { ${sepChip(':', ':')}
-
- case -
+
+ case +
${capChip('lower', 'lower')} ${capChip('upper', 'upper')} ${capChip('first', 'first_of_each')} diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts index d7477fa..e819567 100644 --- a/extension/src/popup/components/settings-vault.ts +++ b/extension/src/popup/components/settings-vault.ts @@ -6,7 +6,7 @@ import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup' import type { VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, } from '../../shared/types'; -import { openGeneratorPopover } from './generator-panel'; +import { openGeneratorPanel, closeGeneratorPanel, isGeneratorPanelOpen } from './generator-panel'; let pendingSettings: VaultSettings | null = null; let activeKeyHandler: ((e: KeyboardEvent) => void) | null = null; @@ -128,7 +128,7 @@ export function renderVaultSettings(app: HTMLElement): void {
generator

${escapeHtml(generatorSummary(pendingSettings.generator_defaults))}

- +
@@ -194,12 +194,18 @@ export function renderVaultSettings(app: HTMLElement): void { }); document.getElementById('configure-gen')?.addEventListener('click', (e) => { - if (!pendingSettings) return; - const anchor = e.currentTarget as HTMLElement; - openGeneratorPopover({ - anchor, + const trigger = e.currentTarget as HTMLElement; + if (isGeneratorPanelOpen()) { + closeGeneratorPanel(); + return; + } + const generatorSection = trigger.closest('.settings-section') as HTMLElement | null; + if (!generatorSection || pendingSettings === null) return; + openGeneratorPanel({ + parent: generatorSection, + trigger, initial: pendingSettings.generator_defaults, - onPicked: () => {/* no-op — user is here to save as default, not pick */}, + context: 'configure-defaults', }); }); diff --git a/extension/src/popup/components/types/login.ts b/extension/src/popup/components/types/login.ts index a055e32..ad617b5 100644 --- a/extension/src/popup/components/types/login.ts +++ b/extension/src/popup/components/types/login.ts @@ -14,7 +14,7 @@ import { renderSectionsEditor, wireSectionsEditor, } 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 /// tickers / intervals / listeners the previous view may have attached. @@ -29,7 +29,7 @@ export function teardown(): void { activeFormEscHandler = null; } sectionsExpanded = false; - closeGeneratorPopover(); + closeGeneratorPanel(); } // ---------------------------------------------------------------------- @@ -240,7 +240,7 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite
- +
@@ -266,11 +266,19 @@ export function renderForm(app: HTMLElement, mode: 'add' | 'edit', existing: Ite wireSectionsEditor(app, sectionsDraft, rerender); 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; - openGeneratorPopover({ - anchor, + openGeneratorPanel({ + parent: passwordRow, // panel mounts inside the password form-group + trigger, initial, + context: 'fill-field', onPicked: (value) => { const pw = document.getElementById('f-password') as HTMLInputElement | null; if (pw) { pw.value = value; pw.type = 'text'; } diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index a698090..29e2597 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -595,66 +595,163 @@ textarea { } .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 panel (gen-UX redesign) --- */ + +.gen-trigger { + background: #7c5719; + color: #fff3cf; + border: none; + 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 { - display: flex; justify-content: space-between; align-items: center; +.gen-trigger:hover { background: #aa812a; } +.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; } -.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; +.gen-panel .panel-toggle button { + flex: 1; + background: transparent; + border: 0; + color: #8b949e; + padding: 5px; + font-size: 11px; + cursor: pointer; + border-radius: 3px; + font-weight: 600; } -.generator-popover .gen-row { - display: flex; align-items: center; gap: 8px; margin: 6px 0; +.gen-panel .panel-toggle button.active { + background: #aa812a; + color: #fff3cf; } -.generator-popover .gen-row__label { - color: #8b949e; width: 70px; flex-shrink: 0; - font-size: 10px; text-transform: lowercase; +.gen-panel .knob { + display: flex; + align-items: center; + gap: 8px; + margin: 6px 0; } -.generator-popover .gen-toggle-group { - display: flex; gap: 0; border: 1px solid #30363d; border-radius: 3px; overflow: hidden; +.gen-panel .knob__label { + color: #8b949e; + width: 56px; + flex-shrink: 0; + font-size: 10px; } -.generator-popover .gen-toggle-group button { - background: transparent; border: 0; color: #8b949e; - padding: 3px 10px; cursor: pointer; font: inherit; font-size: 10px; +.gen-panel .knob__slider { flex: 1; } +.gen-panel .knob__value { + font-family: ui-monospace, monospace; + min-width: 24px; + text-align: right; + color: #c9d1d9; } -.generator-popover .gen-toggle-group button.active { background: #7c5719; 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; +.gen-panel .classes { + display: flex; + gap: 8px; + font-size: 10px; + margin: 6px 0; + flex-wrap: wrap; + color: #8b949e; } -.generator-popover .gen-check-grid { - display: grid; grid-template-columns: 1fr 1fr; - gap: 4px 16px; margin: 6px 0; font-size: 11px; +.gen-panel .classes label { + display: flex; + align-items: center; + gap: 3px; + user-select: none; + cursor: pointer; } -.generator-popover .gen-check-grid label { - display: flex; align-items: center; gap: 6px; +.gen-panel .preview { + 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 { - 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; +.gen-panel .preview__value { + flex: 1; + color: #f1cf6e; + font-family: ui-monospace, monospace; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.generator-popover .gen-preview__regen { - flex-shrink: 0; background: transparent; border: 0; - color: #d2ab43; cursor: pointer; font-size: 12px; +.gen-panel .preview__regen { + background: transparent; + border: 0; + color: #8b949e; + cursor: pointer; + padding: 0 4px; + font-size: 14px; } -.generator-popover .gen-actions { - display: grid; grid-template-columns: 1fr 1fr; - gap: 6px; margin-top: 10px; +.gen-panel .more { + color: #8b949e; + 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-header { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }