mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-19 09:36:59 +03:00
Add live variant mode: element picker, action panel, poll/reply bridge (22 commands)
New feature: /impeccable live starts an interactive visual iteration server. Users select elements in the browser, pick a design action (bolder, quieter, etc.), and the agent generates HTML+CSS variants written directly to source. The dev server's HMR hot-swaps them in, and MutationObserver progressively reveals each variant in a cycler UI as it arrives. Architecture: - src/live/server.mjs: HTTP + WebSocket server with session token auth, long-poll /poll endpoint for the agent, WebSocket for the browser - src/live/poll.mjs: CLI client (npx impeccable poll / poll --reply) - src/live/browser.js: element picker with keyboard nav (arrows=siblings, shift+arrows=parent/child), action panel (12 commands, freeform input, variant count), variant cycler with progressive reveal via MutationObserver - src/live/protocol.mjs: shared message types and event validation - source/skills/impeccable/reference/live.md: agent loop instructions (inject script, poll loop, generate variants, accept/discard, cleanup) CLI changes: - bin/cli.js: added "poll" top-level command - src/detect-antipatterns.mjs: liveCli() now delegates to src/live/server.mjs - package.json: added ws dependency Registered /impeccable live as command #22 across all standard locations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e58cbc432f
commit
bb94dadda0
+2
-111
@@ -3446,118 +3446,9 @@ async function main() {
|
||||
// Live detection server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function findOpenPort(start = 8400) {
|
||||
const net = await import('node:net');
|
||||
return new Promise((resolve) => {
|
||||
const server = net.default.createServer();
|
||||
server.listen(start, '127.0.0.1', () => {
|
||||
const port = server.address().port;
|
||||
server.close(() => resolve(port));
|
||||
});
|
||||
server.on('error', () => resolve(findOpenPort(start + 1)));
|
||||
});
|
||||
}
|
||||
|
||||
const LIVE_PID_FILE = path.join((await import('node:os')).default.tmpdir(), 'impeccable-live.json');
|
||||
|
||||
async function liveCli() {
|
||||
const args = process.argv.slice(2);
|
||||
const helpMode = args.includes('--help');
|
||||
const stopMode = args.includes('stop');
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
const requestedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
|
||||
|
||||
if (helpMode) {
|
||||
console.log(`Usage: impeccable live [options]
|
||||
|
||||
Start a local server that serves the browser detection overlay script.
|
||||
Inject the script into any page to scan for anti-patterns in real time.
|
||||
|
||||
Commands:
|
||||
live Start the server (default)
|
||||
live stop Stop a running live server
|
||||
|
||||
Options:
|
||||
--port=PORT Use a specific port (default: auto-detect unused port)
|
||||
--help Show this help message
|
||||
|
||||
The server provides:
|
||||
/detect.js The detection overlay script (inject via <script> tag)
|
||||
/health Health check endpoint
|
||||
/stop Stop the server remotely`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Stop a running server
|
||||
if (stopMode) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const res = await fetch(`http://localhost:${info.port}/stop`);
|
||||
if (res.ok) {
|
||||
console.log(`Stopped live server on port ${info.port}.`);
|
||||
}
|
||||
} catch {
|
||||
console.log('No running live server found.');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const http = await import('node:http');
|
||||
const scriptPath = path.join(path.dirname(new URL(import.meta.url).pathname), 'detect-antipatterns-browser.js');
|
||||
|
||||
let browserScript;
|
||||
try {
|
||||
browserScript = fs.readFileSync(scriptPath, 'utf-8');
|
||||
} catch {
|
||||
process.stderr.write('Error: Browser script not found. Run `npm run build:browser` first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = requestedPort || await findOpenPort();
|
||||
|
||||
const shutdown = () => {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch { /* ignore */ }
|
||||
server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
const server = http.default.createServer((req, res) => {
|
||||
// CORS headers for cross-origin injection
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
if (req.url === '/detect.js' || req.url === '/') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.end(browserScript);
|
||||
} else if (req.url === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ status: 'ok', port }));
|
||||
} else if (req.url === '/stop') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('stopping');
|
||||
shutdown();
|
||||
} else {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '127.0.0.1', () => {
|
||||
// Write PID file so `live stop` can find us
|
||||
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port }));
|
||||
|
||||
const url = `http://localhost:${port}`;
|
||||
console.log(`Impeccable live detection server running on ${url}\n`);
|
||||
console.log(`Inject into any page:`);
|
||||
console.log(` const s = document.createElement('script');`);
|
||||
console.log(` s.src = '${url}/detect.js';`);
|
||||
console.log(` document.head.appendChild(s);\n`);
|
||||
console.log(`Stop: npx impeccable live stop`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
const { startLiveServer } = await import('./live/server.mjs');
|
||||
await startLiveServer();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,931 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const TOKEN = window.__IMPECCABLE_TOKEN__;
|
||||
const PORT = window.__IMPECCABLE_PORT__;
|
||||
if (!TOKEN || !PORT) {
|
||||
console.warn('[impeccable] Live script loaded without token/port. Aborting.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent double-init
|
||||
if (window.__IMPECCABLE_LIVE_INIT__) return;
|
||||
window.__IMPECCABLE_LIVE_INIT__ = true;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 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 PREFIX = 'impeccable-live';
|
||||
|
||||
const SKIP_TAGS = new Set([
|
||||
'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' },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let state = 'IDLE'; // IDLE | PICKING | CONFIGURING | GENERATING | CYCLING
|
||||
let ws = null;
|
||||
let hoveredElement = null;
|
||||
let selectedElement = null;
|
||||
let currentSessionId = null;
|
||||
let expectedVariants = 0;
|
||||
let arrivedVariants = 0;
|
||||
let visibleVariant = 0;
|
||||
let variantObserver = null;
|
||||
let hasProjectContext = false;
|
||||
|
||||
// UI elements
|
||||
let highlightOverlay = null;
|
||||
let infoTooltip = null;
|
||||
let panelEl = null;
|
||||
let cyclerEl = null;
|
||||
let toastEl = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isOwnElement(el) {
|
||||
if (!el || !el.id) return false;
|
||||
return el.id.startsWith(PREFIX) || el.closest('[id^="' + PREFIX + '"]');
|
||||
}
|
||||
|
||||
function isPickable(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;
|
||||
}
|
||||
|
||||
function elDescriptor(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('.');
|
||||
return s;
|
||||
}
|
||||
|
||||
function genId() {
|
||||
return crypto.randomUUID().replace(/-/g, '').slice(0, 8);
|
||||
}
|
||||
|
||||
function setState(newState) {
|
||||
state = newState;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Highlight overlay
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createHighlight() {
|
||||
const el = document.createElement('div');
|
||||
el.id = PREFIX + '-highlight';
|
||||
Object.assign(el.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',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
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',
|
||||
padding: '2px 6px', borderRadius: '3px',
|
||||
zIndex: Z_HIGHLIGHT + 1,
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'none',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
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';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Element context extraction (sent to agent)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractElementContext(el) {
|
||||
const computed = getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Collect CSS custom properties from stylesheets
|
||||
const customProps = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* cross-origin sheet */ }
|
||||
}
|
||||
|
||||
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': 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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action panel
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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,
|
||||
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',
|
||||
});
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
Object.assign(header.style, {
|
||||
display: 'flex', alignItems: 'center', gap: '6px',
|
||||
marginBottom: '10px', fontSize: '12px', color: ASH,
|
||||
});
|
||||
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);
|
||||
|
||||
// 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',
|
||||
});
|
||||
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',
|
||||
});
|
||||
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',
|
||||
});
|
||||
const countLabel = document.createElement('span');
|
||||
countLabel.textContent = 'Variants';
|
||||
Object.assign(countLabel.style, {
|
||||
fontSize: '11px', fontWeight: '500', color: ASH,
|
||||
textTransform: 'uppercase', letterSpacing: '0.06em',
|
||||
});
|
||||
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);
|
||||
});
|
||||
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',
|
||||
});
|
||||
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);
|
||||
|
||||
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',
|
||||
fontFamily: FONT, fontSize: '12px', fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
prevBtn.addEventListener('click', () => cycleVariant(-1));
|
||||
nextBtn.addEventListener('click', () => cycleVariant(1));
|
||||
acceptBtn.addEventListener('click', handleAccept);
|
||||
discardBtn.addEventListener('click', handleDiscard);
|
||||
|
||||
navRow.appendChild(prevBtn);
|
||||
navRow.appendChild(acceptBtn);
|
||||
navRow.appendChild(discardBtn);
|
||||
navRow.appendChild(nextBtn);
|
||||
el.appendChild(navRow);
|
||||
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function showCycler() {
|
||||
if (!cyclerEl) cyclerEl = createCycler();
|
||||
|
||||
// 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 = '';
|
||||
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',
|
||||
});
|
||||
if (arrived) {
|
||||
dot.addEventListener('click', () => {
|
||||
visibleVariant = i;
|
||||
showVariantInDOM(currentSessionId, i);
|
||||
updateCyclerUI();
|
||||
});
|
||||
}
|
||||
slotsEl.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;
|
||||
}
|
||||
|
||||
function cycleVariant(dir) {
|
||||
const newV = visibleVariant + dir;
|
||||
if (newV < 1 || newV > arrivedVariants) return;
|
||||
visibleVariant = newV;
|
||||
showVariantInDOM(currentSessionId, newV);
|
||||
updateCyclerUI();
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MutationObserver for progressive variant reveal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function startVariantObserver(sessionId) {
|
||||
// Watch the entire body for [data-impeccable-variants] appearing (after HMR)
|
||||
const bodyObserver = 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
|
||||
if (visibleVariant === 0 && arrivedVariants > 0) {
|
||||
visibleVariant = 1;
|
||||
showVariantInDOM(sessionId, 1);
|
||||
}
|
||||
updateCyclerUI();
|
||||
}
|
||||
|
||||
// Read expected count from the wrapper attribute
|
||||
const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
|
||||
if (expected > 0 && expected !== expectedVariants) {
|
||||
expectedVariants = expected;
|
||||
updateCyclerUI();
|
||||
}
|
||||
|
||||
// If all variants have arrived, transition to CYCLING
|
||||
if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
|
||||
setState('CYCLING');
|
||||
}
|
||||
});
|
||||
|
||||
bodyObserver.observe(document.body, { childList: true, subtree: true });
|
||||
return bodyObserver;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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; }
|
||||
|
||||
switch (msg.type) {
|
||||
case 'auth_ok':
|
||||
hasProjectContext = !!msg.hasProjectContext;
|
||||
if (!hasProjectContext) showContextWarning();
|
||||
console.log('[impeccable] Live mode connected.');
|
||||
setState('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();
|
||||
break;
|
||||
case 'error':
|
||||
console.error('[impeccable] Error:', msg.message);
|
||||
showError(msg.message);
|
||||
hideCycler();
|
||||
setState('PICKING');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[impeccable] WebSocket closed. Reconnecting in 3s...');
|
||||
setTimeout(connectWS, 3000);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
function sendWS(msg) {
|
||||
if (ws && ws.readyState === 1) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
if (state !== 'PICKING') return;
|
||||
if (isOwnElement(e.target)) return;
|
||||
const el = hoveredElement;
|
||||
if (!el || !isPickable(el)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectedElement = el;
|
||||
setState('CONFIGURING');
|
||||
positionHighlight(el);
|
||||
showPanel(el);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
} else if (e.key === 'ArrowUp' && !e.shiftKey) {
|
||||
next = hoveredElement.previousElementSibling;
|
||||
while (next && !isPickable(next)) next = next.previousElementSibling;
|
||||
} else if (e.key === 'ArrowUp' && e.shiftKey) {
|
||||
next = hoveredElement.parentElement;
|
||||
if (next && !isPickable(next)) next = null;
|
||||
} else if (e.key === 'ArrowDown' && e.shiftKey) {
|
||||
next = hoveredElement.firstElementChild;
|
||||
while (next && !isPickable(next)) next = next.nextElementSibling;
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
selectedElement = hoveredElement;
|
||||
setState('CONFIGURING');
|
||||
positionHighlight(hoveredElement);
|
||||
showPanel(hoveredElement);
|
||||
return;
|
||||
}
|
||||
if (next) {
|
||||
e.preventDefault();
|
||||
hoveredElement = next;
|
||||
positionHighlight(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); }
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
|
||||
}
|
||||
}
|
||||
|
||||
function handleGo() {
|
||||
if (!selectedElement || state !== 'CONFIGURING') return;
|
||||
|
||||
const action = getSelectedAction();
|
||||
const count = getSelectedCount();
|
||||
const freeformPrompt = getFreeformPrompt();
|
||||
|
||||
currentSessionId = genId();
|
||||
expectedVariants = count;
|
||||
arrivedVariants = 0;
|
||||
visibleVariant = 0;
|
||||
|
||||
// Send generate event to server
|
||||
sendWS({
|
||||
type: 'generate',
|
||||
id: currentSessionId,
|
||||
action: action,
|
||||
freeformPrompt: freeformPrompt || undefined,
|
||||
count: count,
|
||||
element: extractElementContext(selectedElement),
|
||||
});
|
||||
|
||||
hidePanel();
|
||||
setState('GENERATING');
|
||||
|
||||
// Start observing for variants in the DOM
|
||||
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');
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
if (!currentSessionId) return;
|
||||
sendWS({
|
||||
type: 'discard',
|
||||
id: currentSessionId,
|
||||
});
|
||||
hideCycler();
|
||||
hideHighlight();
|
||||
selectedElement = null;
|
||||
currentSessionId = null;
|
||||
setState('PICKING');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Toast / warnings / errors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.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'; });
|
||||
setTimeout(() => {
|
||||
if (toastEl) {
|
||||
toastEl.style.opacity = '0';
|
||||
setTimeout(() => { if (toastEl) toastEl.remove(); toastEl = null; }, 300);
|
||||
}
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Init
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function init() {
|
||||
highlightOverlay = createHighlight();
|
||||
infoTooltip = createInfoTooltip();
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* CLI client for the live variant mode poll/reply protocol.
|
||||
*
|
||||
* Usage:
|
||||
* npx impeccable poll # Block until browser event, print JSON
|
||||
* npx impeccable poll --timeout=60000 # Custom timeout (ms)
|
||||
* npx impeccable poll --reply <id> done # Reply "done" to event <id>
|
||||
* npx impeccable poll --reply <id> error "msg" # Reply with error
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
|
||||
|
||||
function readServerInfo() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
} catch {
|
||||
console.error('No running live server found. Start one with: npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollCli() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.includes('--help') || args.includes('-h')) {
|
||||
console.log(`Usage: impeccable poll [options]
|
||||
|
||||
Wait for a browser event from the live variant server, or reply to one.
|
||||
|
||||
Modes:
|
||||
poll Block until a browser event arrives, print JSON
|
||||
poll --reply <id> done Reply "done" to event <id>
|
||||
poll --reply <id> error "msg" Reply with an error message
|
||||
|
||||
Options:
|
||||
--timeout=MS Poll timeout in milliseconds (default: 120000)
|
||||
--help Show this help message`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const info = readServerInfo();
|
||||
const base = `http://localhost:${info.port}`;
|
||||
|
||||
// Reply mode: npx impeccable poll --reply <id> <status> [message]
|
||||
const replyIdx = args.indexOf('--reply');
|
||||
if (replyIdx !== -1) {
|
||||
const id = args[replyIdx + 1];
|
||||
const status = args[replyIdx + 2] || 'done';
|
||||
const message = args[replyIdx + 3] || undefined;
|
||||
|
||||
if (!id) {
|
||||
console.error('Usage: npx impeccable poll --reply <id> <status> [message]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: info.token,
|
||||
id,
|
||||
type: status,
|
||||
message,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
console.error(`Reply failed (${res.status}):`, body.error || res.statusText);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Success — silent exit (agent doesn't need output for replies)
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
} else {
|
||||
console.error('Reply failed:', err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Poll mode: block until browser event
|
||||
const timeoutArg = args.find(a => a.startsWith('--timeout='));
|
||||
const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 120000;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${base}/poll?token=${info.token}&timeout=${timeout}`);
|
||||
|
||||
if (res.status === 401) {
|
||||
console.error('Authentication failed. The server token may have changed.');
|
||||
console.error('Try restarting: npx impeccable live stop && npx impeccable live');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`Poll failed: ${res.status} ${res.statusText}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const event = await res.json();
|
||||
// Print the event as JSON — the agent reads this from stdout
|
||||
console.log(JSON.stringify(event));
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.error('Live server not running. Start one with: npx impeccable live');
|
||||
} else {
|
||||
console.error('Poll failed:', err.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Shared protocol constants and validation for the live variant mode.
|
||||
* Imported by both server.mjs and poll.mjs.
|
||||
*/
|
||||
|
||||
// Browser → Server event types
|
||||
export const EVENT = Object.freeze({
|
||||
GENERATE: 'generate',
|
||||
ACCEPT: 'accept',
|
||||
DISCARD: 'discard',
|
||||
EXIT: 'exit',
|
||||
});
|
||||
|
||||
// Server → Browser message types
|
||||
export const MSG = Object.freeze({
|
||||
AUTH_OK: 'auth_ok',
|
||||
AUTH_FAIL: 'auth_fail',
|
||||
GENERATING: 'generating',
|
||||
DONE: 'done',
|
||||
ERROR: 'error',
|
||||
});
|
||||
|
||||
// Poll return types (superset of EVENT — adds timeout)
|
||||
export const POLL = Object.freeze({
|
||||
...EVENT,
|
||||
TIMEOUT: 'timeout',
|
||||
});
|
||||
|
||||
// Commands that make sense for visual variant generation.
|
||||
// Shown in the browser action panel dropdown.
|
||||
export const VISUAL_ACTIONS = Object.freeze([
|
||||
'impeccable', // default: freeform design pass
|
||||
'bolder',
|
||||
'quieter',
|
||||
'distill',
|
||||
'polish',
|
||||
'typeset',
|
||||
'colorize',
|
||||
'layout',
|
||||
'adapt',
|
||||
'animate',
|
||||
'delight',
|
||||
'overdrive',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validate a browser event before queuing it for the agent.
|
||||
* Returns null if valid, or an error string if not.
|
||||
*/
|
||||
export function validateEvent(msg) {
|
||||
if (!msg || typeof msg !== 'object' || !msg.type) {
|
||||
return 'Missing or invalid message';
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case EVENT.GENERATE:
|
||||
if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id';
|
||||
if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return `generate: invalid action "${msg.action}"`;
|
||||
if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8';
|
||||
if (!msg.element || typeof msg.element !== 'object') return 'generate: missing element context';
|
||||
if (!msg.element.outerHTML) return 'generate: element must include outerHTML';
|
||||
return null;
|
||||
|
||||
case EVENT.ACCEPT:
|
||||
if (!msg.id || typeof msg.id !== 'string') return 'accept: missing id';
|
||||
if (!msg.variantId) return 'accept: missing variantId';
|
||||
return null;
|
||||
|
||||
case EVENT.DISCARD:
|
||||
if (!msg.id || typeof msg.id !== 'string') return 'discard: missing id';
|
||||
return null;
|
||||
|
||||
case EVENT.EXIT:
|
||||
return null;
|
||||
|
||||
default:
|
||||
return `Unknown event type: "${msg.type}"`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
/**
|
||||
* Live variant mode server.
|
||||
*
|
||||
* Serves the browser script (/live.js), the detection overlay (/detect.js),
|
||||
* manages a WebSocket connection to the browser, and exposes HTTP long-poll
|
||||
* endpoints so the agent CLI can receive events and send replies.
|
||||
*
|
||||
* Start: npx impeccable live
|
||||
* Stop: npx impeccable live stop
|
||||
* Health: curl http://localhost:PORT/health
|
||||
*/
|
||||
|
||||
import http from 'node:http';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { validateEvent } from './protocol.mjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const LIVE_PID_FILE = path.join(os.tmpdir(), 'impeccable-live.json');
|
||||
const DEFAULT_POLL_TIMEOUT = 120_000; // 2 minutes
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Port detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function findOpenPort(start = 8400) {
|
||||
return new Promise((resolve) => {
|
||||
const srv = net.createServer();
|
||||
srv.listen(start, '127.0.0.1', () => {
|
||||
const port = srv.address().port;
|
||||
srv.close(() => resolve(port));
|
||||
});
|
||||
srv.on('error', () => resolve(findOpenPort(start + 1)));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const state = {
|
||||
token: null,
|
||||
port: null,
|
||||
wsClients: new Set(),
|
||||
// Queue: browser events waiting for the agent to poll
|
||||
pendingEvents: [],
|
||||
// Queue: agent poll response callbacks waiting for browser events
|
||||
pendingPolls: [],
|
||||
};
|
||||
|
||||
/** Push an event from the browser into the queue or resolve a waiting poll. */
|
||||
function enqueueEvent(event) {
|
||||
if (state.pendingPolls.length > 0) {
|
||||
const resolve = state.pendingPolls.shift();
|
||||
resolve(event);
|
||||
} else {
|
||||
state.pendingEvents.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast a message to all authenticated WS clients. */
|
||||
function broadcast(msg) {
|
||||
const data = JSON.stringify(msg);
|
||||
for (const ws of state.wsClients) {
|
||||
if (ws.readyState === 1 /* OPEN */) {
|
||||
ws.send(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Load scripts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function loadBrowserScripts() {
|
||||
const detectPath = path.join(__dirname, '..', 'detect-antipatterns-browser.js');
|
||||
const livePath = path.join(__dirname, 'browser.js');
|
||||
|
||||
let detectScript = '';
|
||||
try {
|
||||
detectScript = fs.readFileSync(detectPath, 'utf-8');
|
||||
} catch {
|
||||
// Detection script is optional for the live variant server
|
||||
}
|
||||
|
||||
let liveScript = '';
|
||||
try {
|
||||
liveScript = fs.readFileSync(livePath, 'utf-8');
|
||||
} catch {
|
||||
process.stderr.write('Error: Browser live script not found at ' + livePath + '\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return { detectScript, liveScript };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Check for .impeccable.md
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function hasProjectContext() {
|
||||
try {
|
||||
fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP request handler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createRequestHandler({ detectScript, liveScriptWithToken }) {
|
||||
return (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${state.port}`);
|
||||
|
||||
// CORS
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
|
||||
|
||||
const pathname = url.pathname;
|
||||
|
||||
// --- Public endpoints (no auth) ---
|
||||
|
||||
if (pathname === '/live.js') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.end(liveScriptWithToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/detect.js' || pathname === '/') {
|
||||
if (!detectScript) {
|
||||
res.writeHead(404);
|
||||
res.end('Detection script not available. Run npm run build:browser first.');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
res.end(detectScript);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
status: 'ok',
|
||||
port: state.port,
|
||||
mode: 'variant',
|
||||
hasProjectContext: hasProjectContext(),
|
||||
connectedClients: state.wsClients.size,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Authenticated endpoints ---
|
||||
|
||||
const token = url.searchParams.get('token');
|
||||
|
||||
if (pathname === '/stop') {
|
||||
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('stopping');
|
||||
shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/poll') {
|
||||
if (req.method === 'GET') {
|
||||
handlePollGet(req, res, url);
|
||||
} else if (req.method === 'POST') {
|
||||
handlePollPost(req, res);
|
||||
} else {
|
||||
res.writeHead(405);
|
||||
res.end('Method not allowed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Poll endpoints
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** GET /poll — agent blocks here until a browser event arrives. */
|
||||
function handlePollGet(req, res, url) {
|
||||
const token = url.searchParams.get('token');
|
||||
if (token !== state.token) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
|
||||
|
||||
// If there's already an event queued, return it immediately
|
||||
if (state.pendingEvents.length > 0) {
|
||||
const event = state.pendingEvents.shift();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for one
|
||||
const timer = setTimeout(() => {
|
||||
// Remove this callback from pendingPolls
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ type: 'timeout' }));
|
||||
}, timeout);
|
||||
|
||||
function resolve(event) {
|
||||
clearTimeout(timer);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(event));
|
||||
}
|
||||
|
||||
state.pendingPolls.push(resolve);
|
||||
|
||||
// Clean up if the agent disconnects before we respond
|
||||
req.on('close', () => {
|
||||
clearTimeout(timer);
|
||||
const idx = state.pendingPolls.indexOf(resolve);
|
||||
if (idx !== -1) state.pendingPolls.splice(idx, 1);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST /poll — agent replies to a pending browser event. */
|
||||
function handlePollPost(req, res) {
|
||||
let body = '';
|
||||
req.on('data', (chunk) => { body += chunk; });
|
||||
req.on('end', () => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.token !== state.token) {
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward the reply to the browser
|
||||
broadcast({
|
||||
type: msg.type || 'done',
|
||||
id: msg.id,
|
||||
message: msg.message,
|
||||
data: msg.data,
|
||||
});
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WebSocket handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function setupWebSocket(server) {
|
||||
const wss = new WebSocketServer({ server, path: '/ws' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let authenticated = false;
|
||||
|
||||
ws.on('message', (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// First message must be auth
|
||||
if (!authenticated) {
|
||||
if (msg.type === 'auth' && msg.token === state.token) {
|
||||
authenticated = true;
|
||||
state.wsClients.add(ws);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'auth_ok',
|
||||
hasProjectContext: hasProjectContext(),
|
||||
}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'auth_fail', reason: 'Invalid token' }));
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validated browser events go to the agent poll queue
|
||||
const error = validateEvent(msg);
|
||||
if (error) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: error }));
|
||||
return;
|
||||
}
|
||||
|
||||
enqueueEvent(msg);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
state.wsClients.delete(ws);
|
||||
// If all browser clients disconnected, signal exit to agent
|
||||
if (authenticated && state.wsClients.size === 0) {
|
||||
enqueueEvent({ type: 'exit' });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', () => {
|
||||
state.wsClients.delete(ws);
|
||||
});
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let httpServer = null;
|
||||
let wss = null;
|
||||
|
||||
function shutdown() {
|
||||
try { fs.unlinkSync(LIVE_PID_FILE); } catch { /* ignore */ }
|
||||
// Close all WebSocket connections
|
||||
for (const ws of state.wsClients) {
|
||||
try { ws.close(); } catch { /* ignore */ }
|
||||
}
|
||||
state.wsClients.clear();
|
||||
// Resolve any pending polls with exit
|
||||
for (const resolve of state.pendingPolls) {
|
||||
resolve({ type: 'exit' });
|
||||
}
|
||||
state.pendingPolls.length = 0;
|
||||
if (wss) { try { wss.close(); } catch { /* ignore */ } }
|
||||
if (httpServer) { httpServer.close(); }
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the live variant server.
|
||||
* Called from liveCli() in detect-antipatterns.mjs.
|
||||
*/
|
||||
export async function startLiveServer({ port: requestedPort } = {}) {
|
||||
const args = process.argv.slice(2);
|
||||
const helpMode = args.includes('--help');
|
||||
const stopMode = args.includes('stop');
|
||||
const portArg = args.find(a => a.startsWith('--port='));
|
||||
const parsedPort = portArg ? parseInt(portArg.split('=')[1], 10) : null;
|
||||
|
||||
if (helpMode) {
|
||||
console.log(`Usage: impeccable live [options]
|
||||
|
||||
Start the live variant mode server. Serves the browser overlay script and
|
||||
bridges WebSocket connections from the browser to the agent poll CLI.
|
||||
|
||||
Commands:
|
||||
live Start the server (default)
|
||||
live stop Stop a running live server
|
||||
|
||||
Options:
|
||||
--port=PORT Use a specific port (default: auto-detect starting at 8400)
|
||||
--help Show this help message
|
||||
|
||||
Endpoints:
|
||||
/live.js Browser script for element picker + variant cycling
|
||||
/detect.js Detection overlay script (backwards compatible)
|
||||
/health Health check
|
||||
/ws WebSocket endpoint for browser
|
||||
/poll Long-poll endpoint for agent CLI`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Stop mode
|
||||
if (stopMode) {
|
||||
try {
|
||||
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
|
||||
if (res.ok) {
|
||||
console.log(`Stopped live server on port ${info.port}.`);
|
||||
}
|
||||
} catch {
|
||||
console.log('No running live server found.');
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Check for existing session
|
||||
try {
|
||||
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
|
||||
// Check if the process is actually running
|
||||
try {
|
||||
process.kill(existing.pid, 0);
|
||||
console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
|
||||
console.error('Stop it first: npx impeccable live stop');
|
||||
process.exit(1);
|
||||
} catch {
|
||||
// Process is dead, clean up stale PID file
|
||||
fs.unlinkSync(LIVE_PID_FILE);
|
||||
}
|
||||
} catch {
|
||||
// No PID file — good
|
||||
}
|
||||
|
||||
// Generate session token
|
||||
state.token = randomUUID();
|
||||
state.port = requestedPort || parsedPort || await findOpenPort();
|
||||
|
||||
// Load scripts
|
||||
const { detectScript, liveScript } = loadBrowserScripts();
|
||||
|
||||
// Inject token and port into the live browser script
|
||||
const liveScriptWithToken =
|
||||
`window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
|
||||
`window.__IMPECCABLE_PORT__ = ${state.port};\n` +
|
||||
liveScript;
|
||||
|
||||
// Create HTTP server
|
||||
httpServer = http.createServer(createRequestHandler({ detectScript, liveScriptWithToken }));
|
||||
|
||||
// Attach WebSocket
|
||||
wss = setupWebSocket(httpServer);
|
||||
|
||||
// Start listening
|
||||
httpServer.listen(state.port, '127.0.0.1', () => {
|
||||
// Write PID file with token so poll CLI can authenticate
|
||||
fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: state.port,
|
||||
token: state.token,
|
||||
}));
|
||||
|
||||
const url = `http://localhost:${state.port}`;
|
||||
console.log(`\nImpeccable live variant server running on ${url}`);
|
||||
console.log(`Token: ${state.token}\n`);
|
||||
console.log(`Inject into your page source:`);
|
||||
console.log(` <script src="${url}/live.js"><\/script>\n`);
|
||||
console.log(`Or inject via browser console:`);
|
||||
console.log(` const s = document.createElement('script');`);
|
||||
console.log(` s.src = '${url}/live.js';`);
|
||||
console.log(` document.head.appendChild(s);\n`);
|
||||
console.log(`Agent poll: npx impeccable poll`);
|
||||
console.log(`Stop: npx impeccable live stop`);
|
||||
});
|
||||
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
Reference in New Issue
Block a user