diff --git a/src/live/browser.js b/src/live/browser.js index e7ac24ab4..a542af543 100644 --- a/src/live/browser.js +++ b/src/live/browser.js @@ -5,11 +5,9 @@ * The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__ * before this code. * - * Features: - * - Element picker with hover highlight and keyboard navigation - * - Action panel (command dropdown, freeform input, variant count, go button) - * - Variant cycler with MutationObserver progressive reveal - * - WebSocket connection to the live server + * UI: a single floating bar that morphs between three states — + * configure (pick action + go), generating (progressive dots), and cycling + * (prev/next + accept/discard). Feels like Spotlight, not a modal. */ (function () { 'use strict'; @@ -22,53 +20,54 @@ return; } - // Prevent double-init if (window.__IMPECCABLE_LIVE_INIT__) return; window.__IMPECCABLE_LIVE_INIT__ = true; // --------------------------------------------------------------------------- - // Constants + // Design tokens // --------------------------------------------------------------------------- - const BRAND = 'oklch(55% 0.25 350)'; - const BRAND_HOVER = 'oklch(45% 0.25 350)'; - const BRAND_LIGHT = 'oklch(92% 0.06 350)'; - const INK = 'oklch(15% 0.01 350)'; - const ASH = 'oklch(55% 0 0)'; - const PAPER = 'oklch(98% 0.005 350)'; - const MIST = 'oklch(90% 0.01 350)'; + const C = { + brand: 'oklch(55% 0.25 350)', + brandHov: 'oklch(48% 0.25 350)', + brandSoft: 'oklch(55% 0.25 350 / 0.12)', + ink: 'oklch(15% 0.01 350)', + ash: 'oklch(55% 0 0)', + paper: 'oklch(98% 0.005 350 / 0.92)', + paperSolid:'oklch(98% 0.005 350)', + mist: 'oklch(90% 0.01 350 / 0.6)', + white: 'oklch(99% 0 0)', + }; const FONT = 'system-ui, -apple-system, sans-serif'; const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace'; - const Z_HIGHLIGHT = 99990; - const Z_PANEL = 99995; - const Z_CYCLER = 99995; + const Z = { highlight: 99990, bar: 99995, picker: 99997, toast: 99999 }; + const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint const PREFIX = 'impeccable-live'; const SKIP_TAGS = new Set([ - 'html', 'head', 'body', 'script', 'style', 'link', 'meta', - 'noscript', 'br', 'wbr', + 'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr', ]); - const VISUAL_ACTIONS = [ - { value: 'impeccable', label: 'Auto (freeform)' }, - { value: 'bolder', label: 'Bolder' }, - { value: 'quieter', label: 'Quieter' }, - { value: 'distill', label: 'Distill' }, - { value: 'polish', label: 'Polish' }, - { value: 'typeset', label: 'Typeset' }, - { value: 'colorize', label: 'Colorize' }, - { value: 'layout', label: 'Layout' }, - { value: 'adapt', label: 'Adapt' }, - { value: 'animate', label: 'Animate' }, - { value: 'delight', label: 'Delight' }, - { value: 'overdrive', label: 'Overdrive' }, + const ACTIONS = [ + { value: 'impeccable', label: 'Freeform' }, + { value: 'bolder', label: 'Bolder' }, + { value: 'quieter', label: 'Quieter' }, + { value: 'distill', label: 'Distill' }, + { value: 'polish', label: 'Polish' }, + { value: 'typeset', label: 'Typeset' }, + { value: 'colorize', label: 'Colorize' }, + { value: 'layout', label: 'Layout' }, + { value: 'adapt', label: 'Adapt' }, + { value: 'animate', label: 'Animate' }, + { value: 'delight', label: 'Delight' }, + { value: 'overdrive', label: 'Overdrive' }, ]; // --------------------------------------------------------------------------- // State // --------------------------------------------------------------------------- - let state = 'IDLE'; // IDLE | PICKING | CONFIGURING | GENERATING | CYCLING + let state = 'IDLE'; let ws = null; let hoveredElement = null; let selectedElement = null; @@ -78,544 +77,553 @@ let visibleVariant = 0; let variantObserver = null; let hasProjectContext = false; + let selectedAction = 'impeccable'; + let selectedCount = 3; - // UI elements - let highlightOverlay = null; - let infoTooltip = null; - let panelEl = null; - let cyclerEl = null; + // UI refs + let highlightEl = null; + let tooltipEl = null; + let barEl = null; + let pickerEl = null; let toastEl = null; + let scrollRaf = null; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- - function isOwnElement(el) { - if (!el || !el.id) return false; - return el.id.startsWith(PREFIX) || el.closest('[id^="' + PREFIX + '"]'); + function own(el) { + return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]')); } - function isPickable(el) { + function pickable(el) { if (!el || el.nodeType !== 1) return false; if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false; - if (isOwnElement(el)) return false; - const rect = el.getBoundingClientRect(); - if (rect.width < 20 || rect.height < 20) return false; - return true; + if (own(el)) return false; + const r = el.getBoundingClientRect(); + return r.width >= 20 && r.height >= 20; } - function elDescriptor(el) { + function desc(el) { if (!el) return ''; let s = el.tagName.toLowerCase(); if (el.id) s += '#' + el.id; - if (el.classList.length) s += '.' + [...el.classList].slice(0, 3).join('.'); + else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.'); return s; } - function genId() { - return crypto.randomUUID().replace(/-/g, '').slice(0, 8); - } - - function setState(newState) { - state = newState; - } + function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); } // --------------------------------------------------------------------------- // Highlight overlay // --------------------------------------------------------------------------- - function createHighlight() { - const el = document.createElement('div'); - el.id = PREFIX + '-highlight'; - Object.assign(el.style, { + function initHighlight() { + highlightEl = document.createElement('div'); + highlightEl.id = PREFIX + '-highlight'; + Object.assign(highlightEl.style, { position: 'fixed', top: '0', left: '0', width: '0', height: '0', - border: '2px solid ' + BRAND, - borderRadius: '3px', - pointerEvents: 'none', - zIndex: Z_HIGHLIGHT, - transition: 'top 0.08s ease, left 0.08s ease, width 0.08s ease, height 0.08s ease', - display: 'none', - boxSizing: 'border-box', + border: '2px solid ' + C.brand, borderRadius: '3px', + pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box', + transition: 'top 0.08s ease, left 0.08s ease, width 0.08s ease, height 0.08s ease, opacity 0.15s ease', + display: 'none', opacity: '0', }); - document.body.appendChild(el); - return el; - } + document.body.appendChild(highlightEl); - function createInfoTooltip() { - const el = document.createElement('div'); - el.id = PREFIX + '-info'; - Object.assign(el.style, { - position: 'fixed', top: '0', left: '0', - background: INK, color: PAPER, - fontFamily: MONO, fontSize: '11px', + tooltipEl = document.createElement('div'); + tooltipEl.id = PREFIX + '-tooltip'; + Object.assign(tooltipEl.style, { + position: 'fixed', + background: C.ink, color: C.white, + fontFamily: MONO, fontSize: '10px', fontWeight: '500', padding: '2px 6px', borderRadius: '3px', - zIndex: Z_HIGHLIGHT + 1, - pointerEvents: 'none', - whiteSpace: 'nowrap', - display: 'none', + zIndex: Z.highlight + 1, pointerEvents: 'none', + whiteSpace: 'nowrap', display: 'none', + letterSpacing: '0.02em', }); - document.body.appendChild(el); - return el; + document.body.appendChild(tooltipEl); } - function positionHighlight(el) { - if (!el || !highlightOverlay) return; - const rect = el.getBoundingClientRect(); - Object.assign(highlightOverlay.style, { - top: (rect.top - 2) + 'px', - left: (rect.left - 2) + 'px', - width: (rect.width + 4) + 'px', - height: (rect.height + 4) + 'px', - display: 'block', + function showHighlight(el) { + if (!el || !highlightEl) return; + const r = el.getBoundingClientRect(); + Object.assign(highlightEl.style, { + top: (r.top - 2) + 'px', left: (r.left - 2) + 'px', + width: (r.width + 4) + 'px', height: (r.height + 4) + 'px', + display: 'block', opacity: '1', + }); + tooltipEl.textContent = desc(el); + const tipTop = r.top - 20; + Object.assign(tooltipEl.style, { + top: (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px', + left: Math.max(4, r.left) + 'px', display: 'block', }); - if (infoTooltip) { - infoTooltip.textContent = elDescriptor(el); - const tipTop = rect.top - 22; - Object.assign(infoTooltip.style, { - top: (tipTop < 4 ? rect.bottom + 4 : tipTop) + 'px', - left: Math.max(4, rect.left) + 'px', - display: 'block', - }); - } } function hideHighlight() { - if (highlightOverlay) highlightOverlay.style.display = 'none'; - if (infoTooltip) infoTooltip.style.display = 'none'; + if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; } + if (tooltipEl) tooltipEl.style.display = 'none'; } // --------------------------------------------------------------------------- - // Element context extraction (sent to agent) + // Element context extraction // --------------------------------------------------------------------------- - function extractElementContext(el) { - const computed = getComputedStyle(el); - const rect = el.getBoundingClientRect(); - - // Collect CSS custom properties from stylesheets - const customProps = {}; + function extractContext(el) { + const cs = getComputedStyle(el); + const r = el.getBoundingClientRect(); + const props = {}; for (const sheet of document.styleSheets) { try { for (const rule of sheet.cssRules) { - if (rule.style) { - for (let i = 0; i < rule.style.length; i++) { - const prop = rule.style[i]; - if (prop.startsWith('--') && !customProps[prop]) { - const val = computed.getPropertyValue(prop).trim(); - if (val) customProps[prop] = val; - } + if (rule.style) for (let i = 0; i < rule.style.length; i++) { + const p = rule.style[i]; + if (p.startsWith('--') && !props[p]) { + const v = cs.getPropertyValue(p).trim(); + if (v) props[p] = v; } } } - } catch { /* cross-origin sheet */ } + } catch { /* cross-origin */ } } - return { - tagName: el.tagName.toLowerCase(), - id: el.id || null, + tagName: el.tagName.toLowerCase(), id: el.id || null, classes: [...el.classList], textContent: (el.textContent || '').slice(0, 500), outerHTML: el.outerHTML.slice(0, 10000), computedStyles: { - 'font-family': computed.fontFamily, - 'font-size': computed.fontSize, - 'font-weight': computed.fontWeight, - 'line-height': computed.lineHeight, - 'color': computed.color, - 'background': computed.background, - 'background-color': computed.backgroundColor, - 'padding': computed.padding, - 'margin': computed.margin, - 'display': computed.display, - 'position': computed.position, - 'gap': computed.gap, - 'border-radius': computed.borderRadius, - 'box-shadow': computed.boxShadow, - }, - cssCustomProperties: customProps, - parentContext: el.parentElement ? - '<' + el.parentElement.tagName.toLowerCase() + - (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '') + - (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '') + - '>' : null, - boundingRect: { - width: Math.round(rect.width), - height: Math.round(rect.height), + 'font-family': cs.fontFamily, 'font-size': cs.fontSize, + 'font-weight': cs.fontWeight, 'line-height': cs.lineHeight, + 'color': cs.color, 'background': cs.background, + 'background-color': cs.backgroundColor, + 'padding': cs.padding, 'margin': cs.margin, + 'display': cs.display, 'position': cs.position, + 'gap': cs.gap, 'border-radius': cs.borderRadius, + 'box-shadow': cs.boxShadow, }, + cssCustomProperties: props, + parentContext: el.parentElement + ? '<' + el.parentElement.tagName.toLowerCase() + + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '') + + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '') + + '>' + : null, + boundingRect: { width: Math.round(r.width), height: Math.round(r.height) }, }; } // --------------------------------------------------------------------------- - // Action panel + // The Bar — one floating element, three modes // --------------------------------------------------------------------------- - function createActionPanel() { - const panel = document.createElement('div'); - panel.id = PREFIX + '-panel'; - Object.assign(panel.style, { - position: 'fixed', zIndex: Z_PANEL, - background: PAPER, - border: '1px solid ' + MIST, + function initBar() { + barEl = document.createElement('div'); + barEl.id = PREFIX + '-bar'; + Object.assign(barEl.style, { + position: 'fixed', zIndex: Z.bar, + display: 'none', opacity: '0', + transform: 'translateY(6px)', + transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE, + // Visual + background: C.paper, + backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', + border: '1px solid ' + C.mist, borderRadius: '10px', - padding: '14px 16px', - boxShadow: '0 4px 24px oklch(0% 0 0 / 0.12)', - fontFamily: FONT, - fontSize: '13px', - color: INK, - minWidth: '260px', - maxWidth: '320px', - display: 'none', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', + fontFamily: FONT, fontSize: '13px', color: C.ink, + padding: '6px', + maxWidth: '460px', minWidth: '320px', + }); + document.body.appendChild(barEl); + } + + function positionBar() { + if (!barEl || !selectedElement) return; + const r = selectedElement.getBoundingClientRect(); + const barH = barEl.offsetHeight || 44; + const barW = barEl.offsetWidth || 380; + let top = r.bottom + 8; + let left = r.left + (r.width - barW) / 2; + // Keep in viewport + if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8; + if (top < 8) top = 8; + if (left < 8) left = 8; + if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8; + Object.assign(barEl.style, { top: top + 'px', left: left + 'px' }); + } + + function showBar(mode) { + barEl.innerHTML = ''; + if (mode === 'configure') barEl.appendChild(buildConfigureRow()); + else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + barEl.style.display = 'block'; + positionBar(); + requestAnimationFrame(() => { + barEl.style.opacity = '1'; + barEl.style.transform = 'translateY(0)'; + }); + } + + function hideBar() { + if (!barEl) return; + barEl.style.opacity = '0'; + barEl.style.transform = 'translateY(6px)'; + setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250); + hideActionPicker(); + } + + function updateBarContent(mode) { + if (!barEl || barEl.style.display === 'none') return; + barEl.innerHTML = ''; + if (mode === 'configure') barEl.appendChild(buildConfigureRow()); + else if (mode === 'generating') barEl.appendChild(buildGeneratingRow()); + else if (mode === 'cycling') barEl.appendChild(buildCyclingRow()); + } + + // --- Configure row --- + + function buildConfigureRow() { + const row = el('div', { + display: 'flex', alignItems: 'center', gap: '4px', }); - // Header - const header = document.createElement('div'); - Object.assign(header.style, { - display: 'flex', alignItems: 'center', gap: '6px', - marginBottom: '10px', fontSize: '12px', color: ASH, + // Action pill + const pill = el('button', { + display: 'inline-flex', alignItems: 'center', gap: '4px', + padding: '5px 10px', borderRadius: '6px', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', fontWeight: '500', + border: 'none', cursor: 'pointer', + transition: 'background 0.12s ease, transform 0.1s ease', + whiteSpace: 'nowrap', flexShrink: '0', }); - const brand = document.createElement('span'); - brand.textContent = 'impeccable'; - Object.assign(brand.style, { fontWeight: '600', color: BRAND, fontFamily: MONO, fontSize: '11px' }); - header.appendChild(brand); - const elLabel = document.createElement('span'); - elLabel.id = PREFIX + '-panel-label'; - Object.assign(elLabel.style, { fontFamily: MONO, fontSize: '11px' }); - header.appendChild(elLabel); - panel.appendChild(header); - - // Action select - const actionRow = document.createElement('div'); - Object.assign(actionRow.style, { marginBottom: '8px' }); - const actionLabel = document.createElement('label'); - actionLabel.textContent = 'Action'; - Object.assign(actionLabel.style, { - display: 'block', fontSize: '11px', fontWeight: '500', - color: ASH, marginBottom: '3px', textTransform: 'uppercase', - letterSpacing: '0.06em', - }); - actionRow.appendChild(actionLabel); - const actionSelect = document.createElement('select'); - actionSelect.id = PREFIX + '-action'; - Object.assign(actionSelect.style, { - width: '100%', padding: '6px 8px', borderRadius: '5px', - border: '1px solid ' + MIST, fontFamily: FONT, fontSize: '13px', - background: '#fff', color: INK, cursor: 'pointer', - }); - VISUAL_ACTIONS.forEach(a => { - const opt = document.createElement('option'); - opt.value = a.value; - opt.textContent = a.label; - actionSelect.appendChild(opt); - }); - actionRow.appendChild(actionSelect); - panel.appendChild(actionRow); + pill.textContent = actionLabel() + ' \u25BE'; + pill.addEventListener('mouseenter', () => pill.style.background = C.brandHov); + pill.addEventListener('mouseleave', () => pill.style.background = C.ink); + pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)'); + pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)'); + pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); }); + row.appendChild(pill); // Freeform input - const freeformRow = document.createElement('div'); - Object.assign(freeformRow.style, { marginBottom: '8px' }); - const freeformLabel = document.createElement('label'); - freeformLabel.textContent = 'Instructions (optional)'; - Object.assign(freeformLabel.style, { - display: 'block', fontSize: '11px', fontWeight: '500', - color: ASH, marginBottom: '3px', textTransform: 'uppercase', - letterSpacing: '0.06em', + const input = document.createElement('input'); + input.id = PREFIX + '-input'; + input.type = 'text'; + input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...'; + Object.assign(input.style, { + flex: '1', minWidth: '0', + padding: '5px 8px', borderRadius: '6px', + border: '1px solid transparent', background: 'transparent', + fontFamily: FONT, fontSize: '12px', color: C.ink, + outline: 'none', + transition: 'border-color 0.15s ease, background 0.15s ease', }); - freeformRow.appendChild(freeformLabel); - const freeformInput = document.createElement('input'); - freeformInput.id = PREFIX + '-freeform'; - freeformInput.type = 'text'; - freeformInput.placeholder = 'e.g. make it feel more premium'; - Object.assign(freeformInput.style, { - width: '100%', padding: '6px 8px', borderRadius: '5px', - border: '1px solid ' + MIST, fontFamily: FONT, fontSize: '13px', - boxSizing: 'border-box', + input.addEventListener('focus', () => { + input.style.borderColor = C.mist; + input.style.background = C.white; }); - freeformRow.appendChild(freeformInput); - panel.appendChild(freeformRow); - - // Variant count - const countRow = document.createElement('div'); - Object.assign(countRow.style, { - display: 'flex', alignItems: 'center', gap: '6px', - marginBottom: '12px', + input.addEventListener('blur', () => { + input.style.borderColor = 'transparent'; + input.style.background = 'transparent'; }); - const countLabel = document.createElement('span'); - countLabel.textContent = 'Variants'; - Object.assign(countLabel.style, { - fontSize: '11px', fontWeight: '500', color: ASH, - textTransform: 'uppercase', letterSpacing: '0.06em', + input.addEventListener('keydown', (e) => { + e.stopPropagation(); // Don't trigger element picker keyboard nav + if (e.key === 'Enter') { e.preventDefault(); handleGo(); } + if (e.key === 'Escape') { e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; } }); - countRow.appendChild(countLabel); - [2, 3, 4].forEach(n => { - const btn = document.createElement('button'); - btn.textContent = n; - btn.dataset.count = n; - btn.className = PREFIX + '-count-btn'; - Object.assign(btn.style, { - width: '32px', height: '28px', borderRadius: '5px', - border: '1px solid ' + MIST, background: n === 3 ? BRAND : '#fff', - color: n === 3 ? '#fff' : INK, fontFamily: FONT, fontSize: '13px', - fontWeight: '500', cursor: 'pointer', transition: 'all 0.1s ease', - }); - btn.addEventListener('click', () => { - document.querySelectorAll('.' + PREFIX + '-count-btn').forEach(b => { - Object.assign(b.style, { background: '#fff', color: INK }); - }); - Object.assign(btn.style, { background: BRAND, color: '#fff' }); - }); - countRow.appendChild(btn); + row.appendChild(input); + + // Variant count toggle + const count = el('button', { + padding: '4px 6px', borderRadius: '5px', + border: '1px solid ' + C.mist, background: 'transparent', + fontFamily: MONO, fontSize: '11px', fontWeight: '600', + color: C.ash, cursor: 'pointer', + transition: 'color 0.12s ease, border-color 0.12s ease', + flexShrink: '0', whiteSpace: 'nowrap', }); - panel.appendChild(countRow); - - // Go + Cancel row - const btnRow = document.createElement('div'); - Object.assign(btnRow.style, { display: 'flex', alignItems: 'center', gap: '8px' }); - const goBtn = document.createElement('button'); - goBtn.id = PREFIX + '-go'; - goBtn.textContent = 'Generate'; - Object.assign(goBtn.style, { - flex: '1', padding: '8px 16px', borderRadius: '5px', - border: 'none', background: INK, color: '#fff', - fontFamily: FONT, fontSize: '13px', fontWeight: '600', - cursor: 'pointer', transition: 'background 0.15s ease', + count.textContent = '\u00D7' + selectedCount; + count.title = 'Variants: click to change'; + count.addEventListener('mouseenter', () => { count.style.color = C.ink; count.style.borderColor = C.ink; }); + count.addEventListener('mouseleave', () => { count.style.color = C.ash; count.style.borderColor = C.mist; }); + count.addEventListener('click', (e) => { + e.stopPropagation(); + selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1; + count.textContent = '\u00D7' + selectedCount; }); - goBtn.addEventListener('mouseenter', () => { goBtn.style.background = BRAND; }); - goBtn.addEventListener('mouseleave', () => { goBtn.style.background = INK; }); - goBtn.addEventListener('click', handleGo); - btnRow.appendChild(goBtn); - const cancelLink = document.createElement('button'); - cancelLink.textContent = 'Cancel'; - Object.assign(cancelLink.style, { - background: 'none', border: 'none', color: ASH, - fontFamily: FONT, fontSize: '12px', cursor: 'pointer', - padding: '4px', - }); - cancelLink.addEventListener('click', () => { - hidePanel(); - setState('PICKING'); - }); - btnRow.appendChild(cancelLink); - panel.appendChild(btnRow); + row.appendChild(count); - document.body.appendChild(panel); - return panel; - } - - function showPanel(el) { - if (!panelEl) panelEl = createActionPanel(); - const label = panelEl.querySelector('#' + PREFIX + '-panel-label'); - if (label) label.textContent = elDescriptor(el); - - // Position near element - const rect = el.getBoundingClientRect(); - const panelWidth = 300; - const panelHeight = 280; - let top = rect.bottom + 8; - let left = rect.right - panelWidth; - if (left < 8) left = 8; - if (top + panelHeight > window.innerHeight) top = rect.top - panelHeight - 8; - if (top < 8) top = 8; - if (left + panelWidth > window.innerWidth) left = window.innerWidth - panelWidth - 8; - - Object.assign(panelEl.style, { - top: top + 'px', left: left + 'px', display: 'block', - }); - - // Focus the freeform input - const input = panelEl.querySelector('#' + PREFIX + '-freeform'); - if (input) setTimeout(() => input.focus(), 50); - } - - function hidePanel() { - if (panelEl) panelEl.style.display = 'none'; - } - - function getSelectedAction() { - const select = document.querySelector('#' + PREFIX + '-action'); - return select ? select.value : 'impeccable'; - } - - function getSelectedCount() { - const active = document.querySelector('.' + PREFIX + '-count-btn[style*="' + BRAND + '"]'); - return active ? parseInt(active.dataset.count) : 3; - } - - function getFreeformPrompt() { - const input = document.querySelector('#' + PREFIX + '-freeform'); - return input ? input.value.trim() : ''; - } - - // --------------------------------------------------------------------------- - // Variant cycler - // --------------------------------------------------------------------------- - - function createCycler() { - const el = document.createElement('div'); - el.id = PREFIX + '-cycler'; - Object.assign(el.style, { - position: 'fixed', zIndex: Z_CYCLER, - background: PAPER, - border: '1px solid ' + MIST, - borderRadius: '10px', - padding: '10px 14px', - boxShadow: '0 4px 24px oklch(0% 0 0 / 0.12)', - fontFamily: FONT, fontSize: '13px', color: INK, - display: 'none', - minWidth: '280px', - }); - - // Status line - const statusRow = document.createElement('div'); - statusRow.id = PREFIX + '-cycler-status'; - Object.assign(statusRow.style, { - textAlign: 'center', marginBottom: '8px', - fontSize: '12px', color: ASH, - }); - el.appendChild(statusRow); - - // Slot indicators - const slotsRow = document.createElement('div'); - slotsRow.id = PREFIX + '-cycler-slots'; - Object.assign(slotsRow.style, { - display: 'flex', justifyContent: 'center', gap: '4px', - marginBottom: '10px', - }); - el.appendChild(slotsRow); - - // Navigation - const navRow = document.createElement('div'); - Object.assign(navRow.style, { - display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px', - }); - - const prevBtn = document.createElement('button'); - prevBtn.id = PREFIX + '-cycler-prev'; - prevBtn.textContent = '\u2190'; - const nextBtn = document.createElement('button'); - nextBtn.id = PREFIX + '-cycler-next'; - nextBtn.textContent = '\u2192'; - [prevBtn, nextBtn].forEach(b => { - Object.assign(b.style, { - width: '32px', height: '28px', borderRadius: '5px', - border: '1px solid ' + MIST, background: '#fff', color: INK, - fontFamily: FONT, fontSize: '14px', cursor: 'pointer', - }); - }); - - const acceptBtn = document.createElement('button'); - acceptBtn.id = PREFIX + '-cycler-accept'; - acceptBtn.textContent = 'Accept'; - Object.assign(acceptBtn.style, { - padding: '6px 16px', borderRadius: '5px', - border: 'none', background: INK, color: '#fff', + // Go button + const go = el('button', { + padding: '5px 12px', borderRadius: '6px', + border: 'none', background: C.brand, color: C.white, fontFamily: FONT, fontSize: '12px', fontWeight: '600', cursor: 'pointer', + transition: 'background 0.12s ease, transform 0.1s ease', + flexShrink: '0', whiteSpace: 'nowrap', + }); + go.textContent = 'Go \u2192'; + go.addEventListener('mouseenter', () => go.style.background = C.brandHov); + go.addEventListener('mouseleave', () => go.style.background = C.brand); + go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)'); + go.addEventListener('mouseup', () => go.style.transform = 'scale(1)'); + go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); }); + row.appendChild(go); + + // Auto-focus input after a beat + setTimeout(() => input.focus(), 60); + return row; + } + + // --- Generating row --- + + function buildGeneratingRow() { + const row = el('div', { + display: 'flex', alignItems: 'center', gap: '8px', + padding: '2px 4px', }); - const discardBtn = document.createElement('button'); - discardBtn.id = PREFIX + '-cycler-discard'; - discardBtn.textContent = 'Discard'; - Object.assign(discardBtn.style, { - padding: '6px 12px', borderRadius: '5px', - border: '1px solid ' + MIST, background: '#fff', color: ASH, - fontFamily: FONT, fontSize: '12px', cursor: 'pointer', + // Action label + const label = el('span', { + fontWeight: '600', fontSize: '12px', color: C.ink, + flexShrink: '0', whiteSpace: 'nowrap', + }); + label.textContent = actionLabel(); + row.appendChild(label); + + // Dots + row.appendChild(buildDots(false)); + + // Status + const status = el('span', { + fontSize: '11px', color: C.ash, whiteSpace: 'nowrap', + marginLeft: 'auto', + }); + status.textContent = arrivedVariants < expectedVariants + ? 'Generating ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...' + : 'Done'; + row.appendChild(status); + + return row; + } + + // --- Cycling row --- + + function buildCyclingRow() { + const row = el('div', { + display: 'flex', alignItems: 'center', gap: '6px', + padding: '1px 2px', }); - prevBtn.addEventListener('click', () => cycleVariant(-1)); - nextBtn.addEventListener('click', () => cycleVariant(1)); - acceptBtn.addEventListener('click', handleAccept); - discardBtn.addEventListener('click', handleDiscard); + // Prev + const prev = navBtn('\u2190'); + prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); }); + if (visibleVariant <= 1) prev.style.opacity = '0.3'; + row.appendChild(prev); - navRow.appendChild(prevBtn); - navRow.appendChild(acceptBtn); - navRow.appendChild(discardBtn); - navRow.appendChild(nextBtn); - el.appendChild(navRow); + // Dots (clickable) + row.appendChild(buildDots(true)); - document.body.appendChild(el); - return el; + // Counter + const counter = el('span', { + fontFamily: MONO, fontSize: '11px', fontWeight: '500', + color: C.ash, minWidth: '24px', textAlign: 'center', + }); + counter.textContent = visibleVariant + '/' + arrivedVariants; + row.appendChild(counter); + + // Next + const next = navBtn('\u2192'); + next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); }); + if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3'; + row.appendChild(next); + + // Spacer + row.appendChild(el('div', { flex: '1' })); + + // Accept + const accept = el('button', { + padding: '4px 10px', borderRadius: '5px', + border: 'none', background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '11px', fontWeight: '600', + cursor: 'pointer', transition: 'background 0.12s ease', + whiteSpace: 'nowrap', + }); + accept.textContent = '\u2713 Accept'; + accept.addEventListener('mouseenter', () => accept.style.background = C.brand); + accept.addEventListener('mouseleave', () => accept.style.background = C.ink); + accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); }); + if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; } + row.appendChild(accept); + + // Discard + const discard = el('button', { + padding: '4px 6px', borderRadius: '5px', + border: '1px solid ' + C.mist, background: 'transparent', + fontFamily: FONT, fontSize: '11px', color: C.ash, + cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease', + }); + discard.textContent = '\u2715'; + discard.title = 'Discard all variants'; + discard.addEventListener('mouseenter', () => { discard.style.color = C.ink; discard.style.borderColor = C.ink; }); + discard.addEventListener('mouseleave', () => { discard.style.color = C.ash; discard.style.borderColor = C.mist; }); + discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); }); + row.appendChild(discard); + + return row; } - function showCycler() { - if (!cyclerEl) cyclerEl = createCycler(); + // --- Shared UI builders --- - // Position near the selected element (or its variant wrapper) - const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]'); - const target = wrapper || selectedElement; - if (target) { - const rect = target.getBoundingClientRect(); - let top = rect.bottom + 8; - if (top + 80 > window.innerHeight) top = rect.top - 88; - if (top < 8) top = 8; - const left = Math.max(8, Math.min(rect.left, window.innerWidth - 300)); - Object.assign(cyclerEl.style, { top: top + 'px', left: left + 'px' }); - } - - cyclerEl.style.display = 'block'; - updateCyclerUI(); - } - - function hideCycler() { - if (cyclerEl) cyclerEl.style.display = 'none'; - if (variantObserver) { - variantObserver.disconnect(); - variantObserver = null; - } - } - - function updateCyclerUI() { - if (!cyclerEl) return; - - const statusEl = cyclerEl.querySelector('#' + PREFIX + '-cycler-status'); - const slotsEl = cyclerEl.querySelector('#' + PREFIX + '-cycler-slots'); - - // Update slots - slotsEl.innerHTML = ''; + function buildDots(clickable) { + const container = el('div', { + display: 'flex', alignItems: 'center', gap: '4px', + }); for (let i = 1; i <= expectedVariants; i++) { - const dot = document.createElement('div'); const arrived = i <= arrivedVariants; const active = i === visibleVariant; - Object.assign(dot.style, { - width: '8px', height: '8px', borderRadius: '50%', - background: active ? BRAND : (arrived ? MIST : 'transparent'), - border: '1.5px solid ' + (arrived ? BRAND : MIST), - cursor: arrived ? 'pointer' : 'default', - transition: 'all 0.15s ease', + const dot = el('div', { + width: '7px', height: '7px', borderRadius: '50%', + background: active ? C.brand : (arrived ? C.ash : 'transparent'), + border: '1.5px solid ' + (arrived ? C.brand : C.mist), + transition: 'all 0.2s ' + EASE, + cursor: (clickable && arrived) ? 'pointer' : 'default', + transform: arrived ? 'scale(1)' : 'scale(0.6)', + opacity: arrived ? '1' : '0.4', }); - if (arrived) { - dot.addEventListener('click', () => { - visibleVariant = i; - showVariantInDOM(currentSessionId, i); - updateCyclerUI(); + if (clickable && arrived) { + const idx = i; + dot.addEventListener('click', (e) => { + e.stopPropagation(); + visibleVariant = idx; + showVariantInDOM(currentSessionId, idx); + updateBarContent('cycling'); }); } - slotsEl.appendChild(dot); + container.appendChild(dot); } - - // Update status text - if (arrivedVariants < expectedVariants) { - statusEl.textContent = 'Generating variant ' + (arrivedVariants + 1) + ' of ' + expectedVariants + '...'; - } else { - statusEl.textContent = 'Variant ' + visibleVariant + ' of ' + expectedVariants; - } - - // Enable/disable nav buttons - const prev = cyclerEl.querySelector('#' + PREFIX + '-cycler-prev'); - const next = cyclerEl.querySelector('#' + PREFIX + '-cycler-next'); - const accept = cyclerEl.querySelector('#' + PREFIX + '-cycler-accept'); - if (prev) prev.disabled = visibleVariant <= 1; - if (next) next.disabled = visibleVariant >= arrivedVariants; - if (accept) accept.disabled = arrivedVariants === 0; + return container; } - function cycleVariant(dir) { - const newV = visibleVariant + dir; - if (newV < 1 || newV > arrivedVariants) return; - visibleVariant = newV; - showVariantInDOM(currentSessionId, newV); - updateCyclerUI(); + function navBtn(text) { + const b = el('button', { + width: '26px', height: '26px', borderRadius: '5px', + border: '1px solid ' + C.mist, background: 'transparent', + color: C.ink, fontFamily: FONT, fontSize: '13px', + cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', + transition: 'border-color 0.12s ease, background 0.12s ease', + padding: '0', lineHeight: '1', + }); + b.textContent = text; + b.addEventListener('mouseenter', () => { b.style.borderColor = C.ink; }); + b.addEventListener('mouseleave', () => { b.style.borderColor = C.mist; }); + return b; } + function actionLabel() { + const a = ACTIONS.find(a => a.value === selectedAction); + return a ? a.label : 'Freeform'; + } + + function el(tag, styles) { + const e = document.createElement(tag); + if (styles) Object.assign(e.style, styles); + return e; + } + + // --------------------------------------------------------------------------- + // Action picker popover + // --------------------------------------------------------------------------- + + function initActionPicker() { + pickerEl = document.createElement('div'); + pickerEl.id = PREFIX + '-picker'; + Object.assign(pickerEl.style, { + position: 'fixed', zIndex: Z.picker, + display: 'none', opacity: '0', + transform: 'scale(0.96) translateY(4px)', + transformOrigin: 'bottom left', + transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE, + background: C.paperSolid, + border: '1px solid ' + C.mist, + borderRadius: '10px', + boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)', + padding: '6px', + fontFamily: FONT, + }); + + // Build the chip grid + const grid = el('div', { + display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px', + }); + + ACTIONS.forEach(action => { + const chip = el('button', { + padding: '6px 8px', borderRadius: '6px', + border: 'none', + background: action.value === selectedAction ? C.brandSoft : 'transparent', + color: action.value === selectedAction ? C.brand : C.ink, + fontFamily: FONT, fontSize: '11px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.1s ease, color 0.1s ease', + textAlign: 'center', whiteSpace: 'nowrap', + }); + chip.textContent = action.label; + chip.dataset.action = action.value; + chip.addEventListener('mouseenter', () => { + if (action.value !== selectedAction) chip.style.background = C.brandSoft; + }); + chip.addEventListener('mouseleave', () => { + chip.style.background = action.value === selectedAction ? C.brandSoft : 'transparent'; + }); + chip.addEventListener('click', (e) => { + e.stopPropagation(); + selectedAction = action.value; + hideActionPicker(); + updateBarContent('configure'); + }); + grid.appendChild(chip); + }); + + pickerEl.appendChild(grid); + document.body.appendChild(pickerEl); + } + + function toggleActionPicker() { + if (pickerEl.style.display !== 'none') { hideActionPicker(); return; } + // Rebuild chips to reflect current selection + pickerEl.querySelectorAll('button').forEach(chip => { + const isActive = chip.dataset.action === selectedAction; + chip.style.background = isActive ? C.brandSoft : 'transparent'; + chip.style.color = isActive ? C.brand : C.ink; + }); + // Position above the bar + const barRect = barEl.getBoundingClientRect(); + const pickerH = 90; // approximate + let top = barRect.top - pickerH - 6; + if (top < 8) top = barRect.bottom + 6; + Object.assign(pickerEl.style, { + top: top + 'px', left: barRect.left + 'px', + display: 'block', + }); + requestAnimationFrame(() => { + pickerEl.style.opacity = '1'; + pickerEl.style.transform = 'scale(1) translateY(0)'; + }); + } + + function hideActionPicker() { + if (!pickerEl) return; + pickerEl.style.opacity = '0'; + pickerEl.style.transform = 'scale(0.96) translateY(4px)'; + setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180); + } + + // --------------------------------------------------------------------------- + // Variant cycling in DOM + // --------------------------------------------------------------------------- + function showVariantInDOM(sessionId, num) { const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return; @@ -626,45 +634,65 @@ } } + function cycleVariant(dir) { + const next = visibleVariant + dir; + if (next < 1 || next > arrivedVariants) return; + visibleVariant = next; + showVariantInDOM(currentSessionId, next); + updateBarContent('cycling'); + } + // --------------------------------------------------------------------------- // MutationObserver for progressive variant reveal // --------------------------------------------------------------------------- function startVariantObserver(sessionId) { - // Watch the entire body for [data-impeccable-variants] appearing (after HMR) - const bodyObserver = new MutationObserver(() => { + const obs = new MutationObserver(() => { const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]'); if (!wrapper) return; - - // Count arrived variants const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])'); - const newCount = variants.length; - - if (newCount > arrivedVariants) { - arrivedVariants = newCount; - // Auto-show the first variant + const count = variants.length; + if (count > arrivedVariants) { + arrivedVariants = count; if (visibleVariant === 0 && arrivedVariants > 0) { visibleVariant = 1; showVariantInDOM(sessionId, 1); } - updateCyclerUI(); + // Update bar to reflect new dots + if (state === 'GENERATING') updateBarContent('generating'); + else if (state === 'CYCLING') updateBarContent('cycling'); } - - // Read expected count from the wrapper attribute const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0'); if (expected > 0 && expected !== expectedVariants) { expectedVariants = expected; - updateCyclerUI(); + if (state === 'GENERATING') updateBarContent('generating'); } - - // If all variants have arrived, transition to CYCLING if (arrivedVariants >= expectedVariants && expectedVariants > 0) { - setState('CYCLING'); + state = 'CYCLING'; + updateBarContent('cycling'); } }); + obs.observe(document.body, { childList: true, subtree: true }); + return obs; + } - bodyObserver.observe(document.body, { childList: true, subtree: true }); - return bodyObserver; + // --------------------------------------------------------------------------- + // Bar scroll tracking + // --------------------------------------------------------------------------- + + function startScrollTracking() { + function tick() { + if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') { + positionBar(); + showHighlight(selectedElement); + } + scrollRaf = requestAnimationFrame(tick); + } + scrollRaf = requestAnimationFrame(tick); + } + + function stopScrollTracking() { + if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; } } // --------------------------------------------------------------------------- @@ -673,56 +701,37 @@ function connectWS() { ws = new WebSocket('ws://localhost:' + PORT + '/ws'); - - ws.onopen = () => { - ws.send(JSON.stringify({ type: 'auth', token: TOKEN })); - }; - - ws.onmessage = (event) => { - let msg; - try { msg = JSON.parse(event.data); } catch { return; } - + ws.onopen = () => ws.send(JSON.stringify({ type: 'auth', token: TOKEN })); + ws.onmessage = (e) => { + let msg; try { msg = JSON.parse(e.data); } catch { return; } switch (msg.type) { case 'auth_ok': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showContextWarning(); + if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); console.log('[impeccable] Live mode connected.'); - setState('PICKING'); + state = 'PICKING'; break; case 'auth_fail': console.error('[impeccable] Auth failed:', msg.reason); break; - case 'generating': - // Agent acknowledged the generate request - break; case 'done': - // Agent finished writing all variants - setState('CYCLING'); - updateCyclerUI(); + state = 'CYCLING'; + updateBarContent('cycling'); break; case 'error': console.error('[impeccable] Error:', msg.message); - showError(msg.message); - hideCycler(); - setState('PICKING'); + showToast('Error: ' + msg.message, 5000); + hideBar(); + state = 'PICKING'; break; } }; - - ws.onclose = () => { - console.log('[impeccable] WebSocket closed. Reconnecting in 3s...'); - setTimeout(connectWS, 3000); - }; - - ws.onerror = () => { - // onclose will fire after this - }; + ws.onclose = () => { setTimeout(connectWS, 3000); }; + ws.onerror = () => {}; } function sendWS(msg) { - if (ws && ws.readyState === 1) { - ws.send(JSON.stringify(msg)); - } + if (ws && ws.readyState === 1) ws.send(JSON.stringify(msg)); } // --------------------------------------------------------------------------- @@ -731,76 +740,70 @@ function handleMouseMove(e) { if (state !== 'PICKING') return; - const el = document.elementFromPoint(e.clientX, e.clientY); - if (!el || !isPickable(el) || el === hoveredElement) return; - hoveredElement = el; - positionHighlight(el); + const target = document.elementFromPoint(e.clientX, e.clientY); + if (!target || !pickable(target) || target === hoveredElement) return; + hoveredElement = target; + showHighlight(target); } function handleClick(e) { + // Close action picker on any outside click + if (pickerEl?.style.display !== 'none' && !own(e.target)) { + hideActionPicker(); + } if (state !== 'PICKING') return; - if (isOwnElement(e.target)) return; - const el = hoveredElement; - if (!el || !isPickable(el)) return; + if (own(e.target)) return; + if (!hoveredElement || !pickable(hoveredElement)) return; e.preventDefault(); e.stopPropagation(); - selectedElement = el; - setState('CONFIGURING'); - positionHighlight(el); - showPanel(el); + selectedElement = hoveredElement; + state = 'CONFIGURING'; + showHighlight(selectedElement); + showBar('configure'); + startScrollTracking(); } function handleKeyDown(e) { - // ESC: cancel current state if (e.key === 'Escape') { e.preventDefault(); - if (state === 'CONFIGURING') { - hidePanel(); - setState('PICKING'); - } else if (state === 'CYCLING') { - handleDiscard(); - } else if (state === 'PICKING') { - hideHighlight(); - hidePanel(); - hideCycler(); - setState('IDLE'); - } - return; + if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; } + if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; } + if (state === 'CYCLING') { handleDiscard(); return; } + if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; } } - // Arrow keys: navigate elements if (state === 'PICKING' && hoveredElement) { let next = null; if (e.key === 'ArrowDown' && !e.shiftKey) { next = hoveredElement.nextElementSibling; - while (next && !isPickable(next)) next = next.nextElementSibling; + while (next && !pickable(next)) next = next.nextElementSibling; } else if (e.key === 'ArrowUp' && !e.shiftKey) { next = hoveredElement.previousElementSibling; - while (next && !isPickable(next)) next = next.previousElementSibling; + while (next && !pickable(next)) next = next.previousElementSibling; } else if (e.key === 'ArrowUp' && e.shiftKey) { next = hoveredElement.parentElement; - if (next && !isPickable(next)) next = null; + if (next && !pickable(next)) next = null; } else if (e.key === 'ArrowDown' && e.shiftKey) { next = hoveredElement.firstElementChild; - while (next && !isPickable(next)) next = next.nextElementSibling; + while (next && !pickable(next)) next = next.nextElementSibling; } else if (e.key === 'Enter') { e.preventDefault(); selectedElement = hoveredElement; - setState('CONFIGURING'); - positionHighlight(hoveredElement); - showPanel(hoveredElement); + state = 'CONFIGURING'; + showHighlight(selectedElement); + showBar('configure'); + startScrollTracking(); return; } if (next) { e.preventDefault(); hoveredElement = next; - positionHighlight(next); + showHighlight(next); next.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } return; } - // Variant cycling keys if (state === 'CYCLING') { if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); } if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); } @@ -810,99 +813,79 @@ function handleGo() { if (!selectedElement || state !== 'CONFIGURING') return; + const input = document.getElementById(PREFIX + '-input'); + const prompt = input ? input.value.trim() : ''; - const action = getSelectedAction(); - const count = getSelectedCount(); - const freeformPrompt = getFreeformPrompt(); - - currentSessionId = genId(); - expectedVariants = count; + currentSessionId = id8(); + expectedVariants = selectedCount; arrivedVariants = 0; visibleVariant = 0; - // Send generate event to server sendWS({ - type: 'generate', - id: currentSessionId, - action: action, - freeformPrompt: freeformPrompt || undefined, - count: count, - element: extractElementContext(selectedElement), + type: 'generate', id: currentSessionId, + action: selectedAction, + freeformPrompt: prompt || undefined, + count: selectedCount, + element: extractContext(selectedElement), }); - hidePanel(); - setState('GENERATING'); - - // Start observing for variants in the DOM + state = 'GENERATING'; + showBar('generating'); if (variantObserver) variantObserver.disconnect(); variantObserver = startVariantObserver(currentSessionId); - - // Show cycler in "generating" state - showCycler(); } function handleAccept() { if (!currentSessionId || arrivedVariants === 0) return; - sendWS({ - type: 'accept', - id: currentSessionId, - variantId: String(visibleVariant), - }); - hideCycler(); - hideHighlight(); - selectedElement = null; - currentSessionId = null; - setState('PICKING'); + sendWS({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) }); + cleanup(); } function handleDiscard() { if (!currentSessionId) return; - sendWS({ - type: 'discard', - id: currentSessionId, - }); - hideCycler(); + sendWS({ type: 'discard', id: currentSessionId }); + cleanup(); + } + + function cleanup() { + hideBar(); hideHighlight(); + stopScrollTracking(); + if (variantObserver) { variantObserver.disconnect(); variantObserver = null; } selectedElement = null; currentSessionId = null; - setState('PICKING'); + selectedAction = 'impeccable'; + state = 'PICKING'; } // --------------------------------------------------------------------------- - // Toast / warnings / errors + // Toast // --------------------------------------------------------------------------- - function showContextWarning() { - showToast('Running without project context. Run /impeccable teach for better variants.', 8000); - } - - function showError(message) { - showToast('Error: ' + message, 6000); - } - function showToast(message, duration) { if (toastEl) toastEl.remove(); - toastEl = document.createElement('div'); + toastEl = el('div', { + position: 'fixed', bottom: '16px', left: '50%', + transform: 'translateX(-50%) translateY(8px)', + background: C.ink, color: C.white, + fontFamily: FONT, fontSize: '12px', + padding: '8px 16px', borderRadius: '8px', + zIndex: Z.toast, opacity: '0', + transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE, + pointerEvents: 'none', maxWidth: '420px', textAlign: 'center', + }); toastEl.id = PREFIX + '-toast'; toastEl.textContent = message; - Object.assign(toastEl.style, { - position: 'fixed', bottom: '16px', left: '50%', - transform: 'translateX(-50%)', - background: INK, color: PAPER, - fontFamily: FONT, fontSize: '12px', - padding: '8px 16px', borderRadius: '6px', - zIndex: Z_PANEL + 10, - opacity: '0', - transition: 'opacity 0.3s ease', - pointerEvents: 'none', - maxWidth: '500px', textAlign: 'center', - }); document.body.appendChild(toastEl); - requestAnimationFrame(() => { toastEl.style.opacity = '1'; }); + requestAnimationFrame(() => { + toastEl.style.opacity = '1'; + toastEl.style.transform = 'translateX(-50%) translateY(0)'; + }); setTimeout(() => { if (toastEl) { toastEl.style.opacity = '0'; - setTimeout(() => { if (toastEl) toastEl.remove(); toastEl = null; }, 300); + toastEl.style.transform = 'translateX(-50%) translateY(8px)'; + setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250); } }, duration); } @@ -912,13 +895,12 @@ // --------------------------------------------------------------------------- function init() { - highlightOverlay = createHighlight(); - infoTooltip = createInfoTooltip(); - + initHighlight(); + initBar(); + initActionPicker(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); - connectWS(); console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.'); }