mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
Accept and discard in live variant mode are now handled by a deterministic script (live-accept.mjs) that runs inside the poller before returning to the agent. The browser updates the DOM instantly on click (fire-and-forget) so the user is never blocked waiting for LLM-driven file cleanup. Key changes: - New live-accept.mjs: deterministic accept/discard file operations - Poller auto-runs accept script for accept/discard events (_acceptResult) - Browser handleAccept() now commits DOM change instantly, no SAVING state - CSS+HTML colocated in one write (style tag inside variant wrapper) - SSE heartbeat every 30s prevents silent connection drops - Poll timeout increased from 2min to 10min - EventSource onopen resets retry counter for reliable reconnection - Server --background flag for clean single-command startup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1511 lines
56 KiB
JavaScript
1511 lines
56 KiB
JavaScript
/**
|
||
* Impeccable Live Variant Mode — Browser Script
|
||
*
|
||
* Injected into the user's page via <script src="http://localhost:PORT/live.js">.
|
||
* The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
|
||
* before this code.
|
||
*
|
||
* 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';
|
||
if (typeof window === 'undefined') return;
|
||
|
||
// Guard against double-init. Bun's HTML loader may process the <script> tag
|
||
// and create a bundled copy alongside the external load, or HMR may re-execute.
|
||
// Check BEFORE reading token/port to catch all cases.
|
||
if (window.__IMPECCABLE_LIVE_INIT__) return;
|
||
window.__IMPECCABLE_LIVE_INIT__ = true;
|
||
|
||
const TOKEN = window.__IMPECCABLE_TOKEN__;
|
||
const PORT = window.__IMPECCABLE_PORT__;
|
||
if (!TOKEN || !PORT) {
|
||
window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
|
||
return;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Design tokens
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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';
|
||
// z-index: detect overlays use 99999, so our UI must be above them
|
||
const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
|
||
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',
|
||
]);
|
||
|
||
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';
|
||
let hoveredElement = null;
|
||
let selectedElement = null;
|
||
let currentSessionId = null;
|
||
let expectedVariants = 0;
|
||
let arrivedVariants = 0;
|
||
let visibleVariant = 0;
|
||
let variantObserver = null;
|
||
let hasProjectContext = false;
|
||
let selectedAction = 'impeccable';
|
||
let selectedCount = 3;
|
||
|
||
// UI refs
|
||
let highlightEl = null;
|
||
let tooltipEl = null;
|
||
let barEl = null;
|
||
let pickerEl = null;
|
||
let toastEl = null;
|
||
let scrollRaf = null;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Helpers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function own(el) {
|
||
return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));
|
||
}
|
||
|
||
function pickable(el) {
|
||
if (!el || el.nodeType !== 1) return false;
|
||
if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;
|
||
if (own(el)) return false;
|
||
const r = el.getBoundingClientRect();
|
||
return r.width >= 20 && r.height >= 20;
|
||
}
|
||
|
||
function desc(el) {
|
||
if (!el) return '';
|
||
let s = el.tagName.toLowerCase();
|
||
if (el.id) s += '#' + el.id;
|
||
else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
|
||
return s;
|
||
}
|
||
|
||
function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Highlight overlay
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 ' + C.brand, borderRadius: '3px',
|
||
pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
|
||
// No transition on position/size: avoids layout-property animation detection
|
||
// AND gives instant cursor tracking (no lag)
|
||
transition: 'opacity 0.15s ease',
|
||
display: 'none', opacity: '0',
|
||
});
|
||
document.body.appendChild(highlightEl);
|
||
|
||
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',
|
||
letterSpacing: '0.02em',
|
||
});
|
||
document.body.appendChild(tooltipEl);
|
||
}
|
||
|
||
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',
|
||
});
|
||
}
|
||
|
||
function hideHighlight() {
|
||
if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
|
||
if (tooltipEl) tooltipEl.style.display = 'none';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Element context extraction
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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 p = rule.style[i];
|
||
if (p.startsWith('--') && !props[p]) {
|
||
const v = cs.getPropertyValue(p).trim();
|
||
if (v) props[p] = v;
|
||
}
|
||
}
|
||
}
|
||
} catch { /* cross-origin */ }
|
||
}
|
||
return {
|
||
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': 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) },
|
||
};
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// The Bar — one floating element, three modes
|
||
// ---------------------------------------------------------------------------
|
||
|
||
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',
|
||
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 = '';
|
||
// Reset bar styling
|
||
barEl.style.background = C.paper;
|
||
barEl.style.border = '1px solid ' + C.mist;
|
||
if (mode === 'configure') barEl.appendChild(buildConfigureRow());
|
||
else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
|
||
else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
|
||
else if (mode === 'saving') barEl.appendChild(buildSavingRow());
|
||
else if (mode === 'confirmed') {
|
||
barEl.appendChild(buildConfirmedRow());
|
||
barEl.style.background = 'oklch(95% 0.05 145)';
|
||
barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';
|
||
}
|
||
}
|
||
|
||
// --- Configure row ---
|
||
|
||
function buildConfigureRow() {
|
||
const row = el('div', {
|
||
display: 'flex', alignItems: 'center', gap: '4px',
|
||
});
|
||
|
||
// 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',
|
||
});
|
||
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 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',
|
||
});
|
||
input.addEventListener('focus', () => {
|
||
input.style.borderColor = C.mist;
|
||
input.style.background = C.white;
|
||
});
|
||
input.addEventListener('blur', () => {
|
||
input.style.borderColor = 'transparent';
|
||
input.style.background = 'transparent';
|
||
});
|
||
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'; }
|
||
});
|
||
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',
|
||
});
|
||
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;
|
||
});
|
||
row.appendChild(count);
|
||
|
||
// 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',
|
||
});
|
||
|
||
// 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',
|
||
});
|
||
|
||
// Prev
|
||
const prev = navBtn('\u2190');
|
||
prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
|
||
if (visibleVariant <= 1) prev.style.opacity = '0.3';
|
||
row.appendChild(prev);
|
||
|
||
// Dots (clickable)
|
||
row.appendChild(buildDots(true));
|
||
|
||
// 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;
|
||
}
|
||
|
||
// --- Shared UI builders ---
|
||
|
||
// --- Saving row (waiting for agent to process accept/discard) ---
|
||
|
||
function buildSavingRow() {
|
||
const row = el('div', {
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
padding: '2px 8px',
|
||
});
|
||
const spinner = el('div', {
|
||
width: '14px', height: '14px', borderRadius: '50%',
|
||
border: '2px solid ' + C.mist,
|
||
borderTopColor: C.brand,
|
||
animation: 'impeccable-spin 0.6s linear infinite',
|
||
flexShrink: '0',
|
||
});
|
||
row.appendChild(spinner);
|
||
const label = el('span', {
|
||
fontSize: '12px', color: C.ash, fontWeight: '500',
|
||
});
|
||
label.textContent = 'Applying variant...';
|
||
row.appendChild(label);
|
||
|
||
// Inject the keyframes if not already present
|
||
if (!document.getElementById(PREFIX + '-keyframes')) {
|
||
const style = document.createElement('style');
|
||
style.id = PREFIX + '-keyframes';
|
||
style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';
|
||
document.head.appendChild(style);
|
||
}
|
||
return row;
|
||
}
|
||
|
||
// --- Confirmed row (green success, auto-dismisses) ---
|
||
|
||
function buildConfirmedRow() {
|
||
const row = el('div', {
|
||
display: 'flex', alignItems: 'center', gap: '8px',
|
||
padding: '2px 8px',
|
||
});
|
||
const check = el('span', {
|
||
fontSize: '15px', lineHeight: '1', flexShrink: '0',
|
||
color: 'oklch(45% 0.15 145)',
|
||
});
|
||
check.textContent = '\u2713';
|
||
row.appendChild(check);
|
||
const label = el('span', {
|
||
fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
|
||
});
|
||
label.textContent = 'Variant applied';
|
||
row.appendChild(label);
|
||
return row;
|
||
}
|
||
|
||
// --- Shared UI builders ---
|
||
|
||
function buildDots(clickable) {
|
||
const container = el('div', {
|
||
display: 'flex', alignItems: 'center', gap: '4px',
|
||
});
|
||
for (let i = 1; i <= expectedVariants; i++) {
|
||
const arrived = i <= arrivedVariants;
|
||
const active = i === visibleVariant;
|
||
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 (clickable && arrived) {
|
||
const idx = i;
|
||
dot.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
visibleVariant = idx;
|
||
showVariantInDOM(currentSessionId, idx);
|
||
updateSelectedElement();
|
||
updateBarContent('cycling');
|
||
});
|
||
}
|
||
container.appendChild(dot);
|
||
}
|
||
return container;
|
||
}
|
||
|
||
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;
|
||
for (const child of wrapper.children) {
|
||
const v = child.dataset ? child.dataset.impeccableVariant : null;
|
||
if (!v) continue;
|
||
child.style.display = (v === String(num)) ? '' : 'none';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* No-HMR fallback: fetch the raw source file from the live server,
|
||
* parse it, extract the variant wrapper, and inject it into the live DOM.
|
||
* This works even when the dev server caches HTML (Bun, static servers).
|
||
*/
|
||
function injectVariantsFromSource(filePath, sessionId) {
|
||
const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
|
||
fetch(url)
|
||
.then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
|
||
.then(html => {
|
||
// Parse the raw source HTML
|
||
const parser = new DOMParser();
|
||
const doc = parser.parseFromString(html, 'text/html');
|
||
const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||
if (!srcWrapper) {
|
||
console.error('[impeccable] Variant wrapper not found in source file.');
|
||
return;
|
||
}
|
||
|
||
// Find the original element in the live DOM.
|
||
// The original is inside the wrapper in the source. We find the
|
||
// corresponding element in the live DOM by matching the first child's
|
||
// tag + classes from the original snapshot.
|
||
const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
|
||
if (!origContent) return;
|
||
|
||
const tag = origContent.tagName.toLowerCase();
|
||
const cls = origContent.className;
|
||
let liveEl = null;
|
||
if (origContent.id) {
|
||
liveEl = document.getElementById(origContent.id);
|
||
} else if (cls) {
|
||
// Find by tag + exact class match
|
||
const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
|
||
for (const c of candidates) {
|
||
if (c.className === cls && !own(c)) { liveEl = c; break; }
|
||
}
|
||
}
|
||
|
||
if (!liveEl) {
|
||
console.error('[impeccable] Could not find original element in live DOM.');
|
||
return;
|
||
}
|
||
|
||
// Replace the live element with the full wrapper from source
|
||
const wrapper = srcWrapper.cloneNode(true);
|
||
liveEl.parentElement.replaceChild(wrapper, liveEl);
|
||
|
||
// Update state: count variants, show the first one
|
||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||
arrivedVariants = variants.length;
|
||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
|
||
visibleVariant = 1;
|
||
showVariantInDOM(sessionId, 1);
|
||
|
||
// Update selectedElement to the visible variant's content
|
||
const visEl = wrapper.querySelector('[data-impeccable-variant="1"] > :first-child');
|
||
selectedElement = visEl || wrapper.parentElement;
|
||
|
||
state = 'CYCLING';
|
||
updateBarContent('cycling');
|
||
saveSession();
|
||
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
|
||
})
|
||
.catch(err => {
|
||
console.error('[impeccable] Failed to fetch source:', err);
|
||
showToast('Could not load variants. Try refreshing the page.', 5000);
|
||
});
|
||
}
|
||
|
||
function cycleVariant(dir) {
|
||
const next = visibleVariant + dir;
|
||
if (next < 1 || next > arrivedVariants) return;
|
||
visibleVariant = next;
|
||
showVariantInDOM(currentSessionId, next);
|
||
updateSelectedElement();
|
||
updateBarContent('cycling');
|
||
saveSession();
|
||
}
|
||
|
||
function updateSelectedElement() {
|
||
if (!currentSessionId) return;
|
||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||
if (!wrapper) return;
|
||
const visEl = wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"] > :first-child');
|
||
if (visEl) selectedElement = visEl;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// MutationObserver for progressive variant reveal
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function startVariantObserver(sessionId) {
|
||
let updating = false; // re-entrancy guard
|
||
|
||
const obs = new MutationObserver((mutations) => {
|
||
if (updating) return;
|
||
|
||
// Only react to mutations that add nodes with data-impeccable-variant,
|
||
// or mutations inside the variant wrapper. Ignore our own bar/UI changes.
|
||
let dominated = false;
|
||
for (const m of mutations) {
|
||
if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }
|
||
for (const n of m.addedNodes) {
|
||
if (n.nodeType === 1 && (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant)) {
|
||
dominated = true; break;
|
||
}
|
||
}
|
||
if (dominated) break;
|
||
}
|
||
if (!dominated) return;
|
||
|
||
const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
|
||
if (!wrapper) return;
|
||
|
||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||
const count = variants.length;
|
||
|
||
// Nothing new
|
||
if (count <= arrivedVariants) return;
|
||
|
||
updating = true;
|
||
arrivedVariants = count;
|
||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||
visibleVariant = 1;
|
||
showVariantInDOM(sessionId, 1);
|
||
}
|
||
|
||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||
if (expected > 0) expectedVariants = expected;
|
||
|
||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||
state = 'CYCLING';
|
||
updateBarContent('cycling');
|
||
} else if (state === 'GENERATING') {
|
||
updateBarContent('generating');
|
||
}
|
||
saveSession();
|
||
updating = false;
|
||
});
|
||
|
||
obs.observe(document.body, { childList: true, subtree: true });
|
||
return obs;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 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; }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSE (server→browser) + fetch POST (browser→server)
|
||
// Zero-dependency replacement for WebSocket.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let evtSource = null;
|
||
let sseRetries = 0;
|
||
const SSE_MAX_RETRIES = 20; // generous: heartbeats keep the connection alive, so retries mean real trouble
|
||
|
||
function connectSSE() {
|
||
evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
|
||
|
||
evtSource.onopen = () => {
|
||
sseRetries = 0; // reset on successful (re)connect
|
||
};
|
||
|
||
evtSource.onmessage = (e) => {
|
||
sseRetries = 0; // reset on any successful message
|
||
let msg; try { msg = JSON.parse(e.data); } catch { return; }
|
||
switch (msg.type) {
|
||
case 'connected':
|
||
hasProjectContext = !!msg.hasProjectContext;
|
||
if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000);
|
||
console.log('[impeccable] Live mode connected.');
|
||
if (state === 'IDLE') state = 'PICKING';
|
||
break;
|
||
case 'done':
|
||
// Generate completion: handle no-HMR fallback
|
||
if (arrivedVariants === 0 && expectedVariants > 0 && msg.file) {
|
||
console.log('[impeccable] No HMR detected. Fetching variants from source file...');
|
||
injectVariantsFromSource(msg.file, currentSessionId);
|
||
return;
|
||
}
|
||
if (state === 'GENERATING') {
|
||
state = 'CYCLING';
|
||
updateBarContent('cycling');
|
||
}
|
||
break;
|
||
case 'error':
|
||
console.error('[impeccable] Error:', msg.message);
|
||
showToast('Error: ' + msg.message, 5000);
|
||
hideBar();
|
||
state = 'PICKING';
|
||
break;
|
||
}
|
||
};
|
||
|
||
evtSource.onerror = () => {
|
||
sseRetries++;
|
||
if (sseRetries <= SSE_MAX_RETRIES) {
|
||
console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
|
||
return; // EventSource auto-reconnects
|
||
}
|
||
// Server is gone. Clean up gracefully.
|
||
console.log('[impeccable] Live server unreachable. Cleaning up UI.');
|
||
evtSource.close();
|
||
evtSource = null;
|
||
handleServerLost();
|
||
};
|
||
}
|
||
|
||
/** Server died or became unreachable. Reset UI to a clean state. */
|
||
function handleServerLost() {
|
||
if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
|
||
showToast('Live server disconnected. Session ended.', 5000);
|
||
}
|
||
hideBar();
|
||
hideHighlight();
|
||
stopScrollTracking();
|
||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||
clearSession();
|
||
selectedElement = null;
|
||
currentSessionId = null;
|
||
selectedAction = 'impeccable';
|
||
state = 'IDLE';
|
||
}
|
||
|
||
function sendEvent(msg) {
|
||
msg.token = TOKEN;
|
||
fetch('http://localhost:' + PORT + '/events', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(msg),
|
||
}).catch(err => console.error('[impeccable] Failed to send event:', err));
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Event handlers
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function handleMouseMove(e) {
|
||
if (state !== 'PICKING' || !pickActive) return;
|
||
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' || !pickActive) return;
|
||
if (own(e.target)) return;
|
||
if (!hoveredElement || !pickable(hoveredElement)) return;
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
selectedElement = hoveredElement;
|
||
state = 'CONFIGURING';
|
||
showHighlight(selectedElement);
|
||
showBar('configure');
|
||
startScrollTracking();
|
||
}
|
||
|
||
function handleKeyDown(e) {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }
|
||
if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); state = 'PICKING'; return; }
|
||
if (state === 'CYCLING') { handleDiscard(); return; }
|
||
if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt
|
||
if (state === 'PICKING') { hideHighlight(); state = 'IDLE'; return; }
|
||
}
|
||
|
||
if (state === 'PICKING' && hoveredElement) {
|
||
let next = null;
|
||
if (e.key === 'ArrowDown' && !e.shiftKey) {
|
||
next = hoveredElement.nextElementSibling;
|
||
while (next && !pickable(next)) next = next.nextElementSibling;
|
||
} else if (e.key === 'ArrowUp' && !e.shiftKey) {
|
||
next = hoveredElement.previousElementSibling;
|
||
while (next && !pickable(next)) next = next.previousElementSibling;
|
||
} else if (e.key === 'ArrowUp' && e.shiftKey) {
|
||
next = hoveredElement.parentElement;
|
||
if (next && !pickable(next)) next = null;
|
||
} else if (e.key === 'ArrowDown' && e.shiftKey) {
|
||
next = hoveredElement.firstElementChild;
|
||
while (next && !pickable(next)) next = next.nextElementSibling;
|
||
} else if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
selectedElement = hoveredElement;
|
||
state = 'CONFIGURING';
|
||
showHighlight(selectedElement);
|
||
showBar('configure');
|
||
startScrollTracking();
|
||
return;
|
||
}
|
||
if (next) {
|
||
e.preventDefault();
|
||
hoveredElement = next;
|
||
showHighlight(next);
|
||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (state === 'CYCLING') {
|
||
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }
|
||
if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }
|
||
if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
|
||
}
|
||
}
|
||
|
||
function handleGo() {
|
||
if (!selectedElement || state !== 'CONFIGURING') return;
|
||
const input = document.getElementById(PREFIX + '-input');
|
||
const prompt = input ? input.value.trim() : '';
|
||
|
||
currentSessionId = id8();
|
||
expectedVariants = selectedCount;
|
||
arrivedVariants = 0;
|
||
visibleVariant = 0;
|
||
|
||
sendEvent({
|
||
type: 'generate', id: currentSessionId,
|
||
action: selectedAction,
|
||
freeformPrompt: prompt || undefined,
|
||
count: selectedCount,
|
||
pageUrl: location.pathname,
|
||
element: extractContext(selectedElement),
|
||
});
|
||
|
||
state = 'GENERATING';
|
||
showBar('generating');
|
||
saveSession();
|
||
if (variantObserver) variantObserver.disconnect();
|
||
variantObserver = startVariantObserver(currentSessionId);
|
||
}
|
||
|
||
function handleAccept() {
|
||
if (!currentSessionId || arrivedVariants === 0) return;
|
||
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
|
||
markSessionHandled();
|
||
|
||
// Instantly commit the accepted variant in the DOM (fire-and-forget)
|
||
var wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||
if (wrapper) {
|
||
var accepted = wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
|
||
if (accepted && accepted.firstElementChild) {
|
||
var parent = wrapper.parentElement;
|
||
if (parent) parent.replaceChild(accepted.firstElementChild.cloneNode(true), wrapper);
|
||
}
|
||
}
|
||
|
||
state = 'CONFIRMED';
|
||
updateBarContent('confirmed');
|
||
setTimeout(function() {
|
||
hideBar();
|
||
hideHighlight();
|
||
stopScrollTracking();
|
||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||
clearSession();
|
||
selectedElement = null;
|
||
currentSessionId = null;
|
||
selectedAction = 'impeccable';
|
||
state = 'PICKING';
|
||
}, 1800);
|
||
}
|
||
|
||
function handleDiscard() {
|
||
if (!currentSessionId) return;
|
||
sendEvent({ type: 'discard', id: currentSessionId });
|
||
markSessionHandled();
|
||
// Instant DOM restore + fire-and-forget (script handles file cleanup)
|
||
cleanup();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Session persistence via localStorage
|
||
// ---------------------------------------------------------------------------
|
||
// Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
|
||
|
||
const LS_KEY = PREFIX + '-session';
|
||
|
||
function saveSession() {
|
||
if (!currentSessionId) return;
|
||
try {
|
||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||
id: currentSessionId,
|
||
state: state,
|
||
action: selectedAction,
|
||
count: selectedCount,
|
||
expected: expectedVariants,
|
||
arrived: arrivedVariants,
|
||
visible: visibleVariant,
|
||
}));
|
||
} catch { /* quota exceeded or private mode */ }
|
||
}
|
||
|
||
function loadSession() {
|
||
try {
|
||
const raw = localStorage.getItem(LS_KEY);
|
||
return raw ? JSON.parse(raw) : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function clearSession() {
|
||
try { localStorage.removeItem(LS_KEY); } catch {}
|
||
}
|
||
|
||
/** Mark session as handled (accepted/discarded). The agent will clean up
|
||
* the source, but until it does the wrapper is still in the HTML. This
|
||
* prevents resumeSession from picking it up again after reload. */
|
||
function markSessionHandled() {
|
||
if (!currentSessionId) return;
|
||
try {
|
||
localStorage.setItem(LS_KEY + '-handled', currentSessionId);
|
||
} catch {}
|
||
}
|
||
|
||
function isSessionHandled(id) {
|
||
try {
|
||
return localStorage.getItem(LS_KEY + '-handled') === id;
|
||
} catch { return false; }
|
||
}
|
||
|
||
function clearHandled() {
|
||
try { localStorage.removeItem(LS_KEY + '-handled'); } catch {}
|
||
}
|
||
|
||
function cleanup() {
|
||
// Remove any leftover variant wrapper from the live DOM.
|
||
// After discard, the agent cleans the source, but on dev servers without
|
||
// HMR the DOM still has the old wrapper, which confuses the picker.
|
||
if (currentSessionId) {
|
||
const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
|
||
if (wrapper) {
|
||
// Restore the original element into the DOM
|
||
const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
|
||
if (orig) {
|
||
const content = orig.firstElementChild;
|
||
if (content) {
|
||
wrapper.parentElement.replaceChild(content, wrapper);
|
||
} else {
|
||
wrapper.remove();
|
||
}
|
||
} else {
|
||
wrapper.remove();
|
||
}
|
||
}
|
||
}
|
||
hideBar();
|
||
hideHighlight();
|
||
stopScrollTracking();
|
||
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
|
||
clearSession();
|
||
selectedElement = null;
|
||
currentSessionId = null;
|
||
selectedAction = 'impeccable';
|
||
state = 'PICKING';
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Toast
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function showToast(message, duration) {
|
||
if (toastEl) toastEl.remove();
|
||
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;
|
||
document.body.appendChild(toastEl);
|
||
requestAnimationFrame(() => {
|
||
toastEl.style.opacity = '1';
|
||
toastEl.style.transform = 'translateX(-50%) translateY(0)';
|
||
});
|
||
setTimeout(() => {
|
||
if (toastEl) {
|
||
toastEl.style.opacity = '0';
|
||
toastEl.style.transform = 'translateX(-50%) translateY(8px)';
|
||
setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
|
||
}
|
||
}, duration);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// Resume an active variant session after HMR/page reload.
|
||
// If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
|
||
// variants before HMR fired. Pick up where we left off.
|
||
function resumeSession() {
|
||
const wrapper = document.querySelector('[data-impeccable-variants]');
|
||
if (!wrapper) { clearSession(); clearHandled(); return false; }
|
||
|
||
const sessionId = wrapper.dataset.impeccableVariants;
|
||
|
||
// Don't resume if this session was already accepted/discarded
|
||
if (isSessionHandled(sessionId)) return false;
|
||
|
||
currentSessionId = sessionId;
|
||
expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||
const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
|
||
arrivedVariants = variants.length;
|
||
|
||
// Restore state from localStorage if available
|
||
const saved = loadSession();
|
||
if (saved && saved.id === sessionId) {
|
||
visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
|
||
if (saved.action) selectedAction = saved.action;
|
||
if (saved.count) selectedCount = saved.count;
|
||
} else {
|
||
visibleVariant = arrivedVariants > 0 ? 1 : 0;
|
||
}
|
||
|
||
// Find the visible variant's content element for highlight positioning.
|
||
// Try the visible variant first, fall back to the original's content.
|
||
const visEl = wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"] > :first-child');
|
||
const origEl = wrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
|
||
selectedElement = visEl || origEl || wrapper.parentElement;
|
||
|
||
// Set display state BEFORE starting observer (avoid triggering it)
|
||
if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
|
||
|
||
state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
|
||
showBar(state === 'CYCLING' ? 'cycling' : 'generating');
|
||
startScrollTracking();
|
||
saveSession();
|
||
|
||
// Start observing for more variants AFTER initial setup
|
||
if (variantObserver) variantObserver.disconnect();
|
||
variantObserver = startVariantObserver(currentSessionId);
|
||
return true;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Global bar (always visible at bottom)
|
||
// ---------------------------------------------------------------------------
|
||
|
||
let globalBarEl = null;
|
||
let detectActive = false;
|
||
let pickActive = true;
|
||
let detectCount = 0;
|
||
let detectScriptLoaded = false;
|
||
|
||
function initGlobalBar() {
|
||
globalBarEl = el('div', {
|
||
position: 'fixed', bottom: '14px', left: '50%',
|
||
transform: 'translateX(-50%) translateY(20px)',
|
||
zIndex: Z.bar + 5,
|
||
display: 'flex', alignItems: 'center',
|
||
padding: '4px 5px', gap: '2px',
|
||
background: C.paper,
|
||
backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
|
||
border: '1px solid ' + C.mist,
|
||
borderRadius: '10px',
|
||
boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)',
|
||
fontFamily: FONT, fontSize: '12px',
|
||
opacity: '0',
|
||
transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
|
||
});
|
||
globalBarEl.id = PREFIX + '-global-bar';
|
||
|
||
// Brand
|
||
const brand = el('span', {
|
||
fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em',
|
||
color: C.brand, fontFamily: MONO,
|
||
padding: '0 6px 0 4px',
|
||
});
|
||
brand.textContent = 'Impeccable';
|
||
globalBarEl.appendChild(brand);
|
||
|
||
// Detect toggle: eye icon + label + integrated badge
|
||
const detectBtn = el('button', {
|
||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||
padding: '5px 10px', borderRadius: '7px',
|
||
border: 'none', background: 'transparent',
|
||
color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||
cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap',
|
||
});
|
||
detectBtn.id = PREFIX + '-detect-toggle';
|
||
// Eye icon (SVG)
|
||
detectBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||
const detectLabel = el('span', {});
|
||
detectLabel.textContent = 'Detect';
|
||
detectBtn.appendChild(detectLabel);
|
||
// Badge is inside the button
|
||
const detectBadge = el('span', {
|
||
fontSize: '10px', fontWeight: '600',
|
||
padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',
|
||
background: C.brand, color: C.white,
|
||
display: 'none', fontFamily: MONO,
|
||
});
|
||
detectBadge.id = PREFIX + '-detect-badge';
|
||
detectBtn.appendChild(detectBadge);
|
||
detectBtn.addEventListener('click', () => toggleDetect());
|
||
detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; });
|
||
detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; });
|
||
globalBarEl.appendChild(detectBtn);
|
||
|
||
// Pick toggle: crosshair icon + label
|
||
const pickBtn = el('button', {
|
||
display: 'inline-flex', alignItems: 'center', gap: '5px',
|
||
padding: '5px 10px', borderRadius: '7px',
|
||
border: 'none', background: C.brandSoft,
|
||
color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
|
||
cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap',
|
||
});
|
||
pickBtn.id = PREFIX + '-pick-toggle';
|
||
pickBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>';
|
||
const pickLabel = el('span', {});
|
||
pickLabel.textContent = 'Pick';
|
||
pickBtn.appendChild(pickLabel);
|
||
pickBtn.addEventListener('click', () => togglePick());
|
||
globalBarEl.appendChild(pickBtn);
|
||
|
||
// Exit (subtle × on the right)
|
||
const exitBtn = el('button', {
|
||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||
width: '24px', height: '24px', borderRadius: '6px',
|
||
border: 'none', background: 'transparent',
|
||
color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1',
|
||
cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
|
||
marginLeft: '2px',
|
||
});
|
||
exitBtn.textContent = '\u00D7';
|
||
exitBtn.title = 'Exit live mode';
|
||
exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; });
|
||
exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; });
|
||
exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
|
||
globalBarEl.appendChild(exitBtn);
|
||
|
||
document.body.appendChild(globalBarEl);
|
||
|
||
requestAnimationFrame(() => {
|
||
globalBarEl.style.opacity = '1';
|
||
globalBarEl.style.transform = 'translateX(-50%) translateY(0)';
|
||
});
|
||
|
||
// Listen for detection results AND ready signal
|
||
window.addEventListener('message', onDetectMessage);
|
||
}
|
||
|
||
function updateGlobalBarState() {
|
||
const detectToggle = document.getElementById(PREFIX + '-detect-toggle');
|
||
const detectBadge = document.getElementById(PREFIX + '-detect-badge');
|
||
const pickToggle = document.getElementById(PREFIX + '-pick-toggle');
|
||
|
||
if (detectToggle) {
|
||
detectToggle.style.background = detectActive ? C.brandSoft : 'transparent';
|
||
detectToggle.style.color = detectActive ? C.brand : C.ash;
|
||
}
|
||
if (detectBadge) {
|
||
detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
|
||
detectBadge.textContent = detectCount;
|
||
}
|
||
if (pickToggle) {
|
||
pickToggle.style.background = pickActive ? C.brandSoft : 'transparent';
|
||
pickToggle.style.color = pickActive ? C.brand : C.ash;
|
||
}
|
||
|
||
// When pick is active, make detect overlays click-through so the picker works
|
||
document.querySelectorAll('.impeccable-overlay').forEach(o => {
|
||
o.style.pointerEvents = pickActive ? 'none' : '';
|
||
});
|
||
}
|
||
|
||
let detectReady = false; // true once detect script posts 'impeccable-ready'
|
||
let detectPendingScan = false; // scan requested before script was ready
|
||
|
||
function toggleDetect() {
|
||
detectActive = !detectActive;
|
||
updateGlobalBarState();
|
||
|
||
if (detectActive) {
|
||
if (!detectScriptLoaded) {
|
||
detectPendingScan = true;
|
||
loadDetectScript();
|
||
} else if (detectReady) {
|
||
window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
|
||
} else {
|
||
detectPendingScan = true;
|
||
}
|
||
} else {
|
||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||
detectCount = 0;
|
||
updateGlobalBarState();
|
||
}
|
||
}
|
||
|
||
function togglePick() {
|
||
pickActive = !pickActive;
|
||
updateGlobalBarState();
|
||
|
||
if (!pickActive) {
|
||
hideHighlight();
|
||
if (state === 'PICKING') state = 'IDLE';
|
||
} else {
|
||
if (state === 'IDLE') state = 'PICKING';
|
||
}
|
||
}
|
||
|
||
function loadDetectScript() {
|
||
if (detectScriptLoaded) return;
|
||
detectScriptLoaded = true;
|
||
const s = document.createElement('script');
|
||
s.src = 'http://localhost:' + PORT + '/detect.js';
|
||
s.dataset.impeccableExtension = 'true';
|
||
document.head.appendChild(s);
|
||
}
|
||
|
||
function onDetectMessage(e) {
|
||
if (!e.data || typeof e.data.source !== 'string') return;
|
||
// Detection script is loaded and ready
|
||
if (e.data.source === 'impeccable-ready') {
|
||
detectReady = true;
|
||
if (detectPendingScan && detectActive) {
|
||
detectPendingScan = false;
|
||
window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
|
||
}
|
||
}
|
||
// Scan results arrived
|
||
if (e.data.source === 'impeccable-results') {
|
||
detectCount = e.data.count || 0;
|
||
updateGlobalBarState();
|
||
}
|
||
}
|
||
|
||
/** Full teardown: remove all UI, disconnect SSE, clean up. */
|
||
function teardown() {
|
||
cleanup();
|
||
hideBar();
|
||
if (globalBarEl) {
|
||
globalBarEl.style.transform = 'translateY(100%)';
|
||
setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300);
|
||
}
|
||
if (highlightEl) { highlightEl.remove(); highlightEl = null; }
|
||
if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
|
||
if (barEl) { barEl.remove(); barEl = null; }
|
||
if (pickerEl) { pickerEl.remove(); pickerEl = null; }
|
||
if (evtSource) { evtSource.close(); evtSource = null; }
|
||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||
document.removeEventListener('click', handleClick, true);
|
||
document.removeEventListener('keydown', handleKeyDown, true);
|
||
window.removeEventListener('message', onDetectMessage);
|
||
// Remove detection overlays
|
||
window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
|
||
state = 'IDLE';
|
||
window.__IMPECCABLE_LIVE_INIT__ = false;
|
||
console.log('[impeccable] Live mode exited.');
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Init
|
||
// ---------------------------------------------------------------------------
|
||
|
||
function init() {
|
||
initHighlight();
|
||
initBar();
|
||
initActionPicker();
|
||
initGlobalBar();
|
||
document.addEventListener('mousemove', handleMouseMove, true);
|
||
document.addEventListener('click', handleClick, true);
|
||
document.addEventListener('keydown', handleKeyDown, true);
|
||
connectSSE();
|
||
|
||
// Check for an active session to resume (variant wrapper already in DOM after HMR)
|
||
if (!resumeSession()) {
|
||
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
|
||
} else {
|
||
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
|
||
}
|
||
}
|
||
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|