Instant accept/discard for live mode, SSE heartbeats, background server startup

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>
This commit is contained in:
Paul Bakaus
2026-04-13 16:06:03 -07:00
co-authored by Claude Opus 4.6
parent 9b573de1fb
commit 830fe8e5fc
61 changed files with 5602 additions and 728 deletions
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
+30 -22
View File
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
+30 -22
View File
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
+32 -1
View File
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
+30 -22
View File
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
+34 -32
View File
@@ -40,6 +40,7 @@
<link rel="stylesheet" href="./css/main.css">
<link rel="stylesheet" href="./css/sub-pages.css">
</head>
<body>
<!-- Skip to main content link for keyboard users -->
@@ -85,40 +86,40 @@
<div class="hero-combined-container">
<!-- Left: Title & Info -->
<div class="hero-combined-left">
<h1 class="hero-title-combined">Impeccable</h1>
<p class="hero-tagline-combined">Design fluency for AI harnesses</p>
<h1 class="hero-title-combined">Impeccable</h1>
<p class="hero-tagline-combined">Design fluency for AI harnesses</p>
<p class="hero-hook-text hero-hook-text--full">Great design prompts require design vocabulary. Most people don't have it. Impeccable teaches your AI deep design knowledge and gives you 22 commands to steer the result.</p>
<p class="hero-hook-text hero-hook-text--short">Impeccable teaches your AI real design and gives you 22 commands to steer the result.</p>
<p class="hero-hook-text hero-hook-text--full">Great design prompts require design vocabulary. Most people don't have it. Impeccable teaches your AI deep design knowledge and gives you 22 commands to steer the result.</p>
<p class="hero-hook-text hero-hook-text--short">Impeccable teaches your AI real design and gives you 22 commands to steer the result.</p>
<div class="hero-included-box">
<span class="hero-included-title">What's included</span>
<div class="hero-included-items">
<span><em>Impeccable</em> agent skill with 22 design commands</span>
<span class="hero-included-sep">·</span>
<span>Optional CLI + Chrome extension</span>
</div>
</div>
<div class="hero-included-box">
<span class="hero-included-title">What's included</span>
<div class="hero-included-items">
<span><em>Impeccable</em> agent skill with 22 design commands</span>
<span class="hero-included-sep">·</span>
<span>Optional CLI + Chrome extension</span>
</div>
</div>
<div class="hero-cta-group">
<a href="#downloads" class="hero-cta-combined">Get Started</a>
<div class="hero-logos-inline">
<span class="hero-logos-label">Works with</span>
<div class="hero-logos-row">
<span class="hero-logo-icon has-tooltip" data-tooltip="Cursor"><img src="assets/cursor-logo.png" alt="Cursor" width="24" height="24" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Claude Code"><img src="assets/claude-logo.png" alt="Claude Code" width="24" height="24" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Gemini CLI"><img src="assets/gemini-logo.png" alt="Gemini CLI" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Codex CLI"><img src="assets/openai-logo.png" alt="Codex CLI" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="VS Code Copilot"><img src="assets/github-logo.png" alt="VS Code Copilot" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Antigravity"><img src="assets/antigravity-logo.png" alt="Antigravity" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Kiro"><img src="assets/kiro-logo.png" alt="Kiro" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="OpenCode"><img src="assets/opencode-logo.png" alt="OpenCode" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Pi"><img src="assets/pi-logo.svg" alt="Pi" width="20" height="20" loading="lazy" style="background:#1a1a1a;border-radius:50%;padding:3px"></span>
</div>
</div>
</div>
<p class="hero-version-link"><a href="#changelog">v3.0: 1 skill, 22 commands</a></p>
</div>
<div class="hero-cta-group">
<a href="#downloads" class="hero-cta-combined">Get Started</a>
<div class="hero-logos-inline">
<span class="hero-logos-label">Works with</span>
<div class="hero-logos-row">
<span class="hero-logo-icon has-tooltip" data-tooltip="Cursor"><img src="assets/cursor-logo.png" alt="Cursor" width="24" height="24" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Claude Code"><img src="assets/claude-logo.png" alt="Claude Code" width="24" height="24" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Gemini CLI"><img src="assets/gemini-logo.png" alt="Gemini CLI" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Codex CLI"><img src="assets/openai-logo.png" alt="Codex CLI" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="VS Code Copilot"><img src="assets/github-logo.png" alt="VS Code Copilot" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Antigravity"><img src="assets/antigravity-logo.png" alt="Antigravity" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Kiro"><img src="assets/kiro-logo.png" alt="Kiro" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="OpenCode"><img src="assets/opencode-logo.png" alt="OpenCode" width="20" height="20" loading="lazy"></span>
<span class="hero-logo-icon has-tooltip" data-tooltip="Pi"><img src="assets/pi-logo.svg" alt="Pi" width="20" height="20" loading="lazy" style="background:#1a1a1a;border-radius:50%;padding:3px"></span>
</div>
</div>
</div>
<p class="hero-version-link"><a href="#changelog">v3.0: 1 skill, 22 commands</a></p>
</div>
<!-- Right: Before/After Demo -->
<div class="hero-combined-right">
@@ -206,7 +207,7 @@
<p class="section-lead" data-reveal>22 commands form a shared vocabulary between you and your AI. Each one encodes a specific design discipline, so you can steer with precision.</p>
<div class="solution-visual-interactive" id="framework-viz-container" data-reveal>
<!-- Periodic table generated by JS -->
<!-- Periodic table generated by JS -->
</div>
<div id="commands-section" style="height:0;overflow:hidden;visibility:hidden"></div>
@@ -755,5 +756,6 @@
<script type="module" src="./app.js"></script>
</body>
</html>
+27 -35
View File
@@ -7,13 +7,11 @@ Launch interactive live variant mode: select elements in the browser, pick a des
## Start the Server
1. Read `.impeccable.md` if it exists. Keep the design context in mind for variant generation.
2. Start the live variant server and read its connection info:
2. Start the live variant server in the background. The `--background` flag spawns a detached server process, waits for it to be ready, prints the connection JSON to stdout, and exits:
```bash
node {{scripts_path}}/live-server.mjs &
sleep 2
cat .impeccable-live.json
node {{scripts_path}}/live-server.mjs --background
```
The JSON contains `port` and `token`. Use the port for the script tag below.
The output JSON contains `port` and `token`. Use the port for the script tag below.
## Inject the Browser Script
@@ -108,10 +106,14 @@ If `wrap` fails, fall back to manual grep + edit.
4. **If a freeform prompt was provided** (`event.freeformPrompt`), use it as additional guidance for all variants.
5. **Write all variants in a single file edit** at the insert line reported by `wrap`. Use the comment syntax from the `wrap` output:
5. **Write CSS + HTML together in a SINGLE edit** at the insert line reported by `wrap`. Colocate any scoped CSS inside the variant wrapper as a `<style>` tag. `<style>` tags work anywhere in the document in all modern browsers, and this ensures CSS and HTML arrive atomically (no flash of unstyled content).
```html
<!-- Variants: insert below this line -->
<style data-impeccable-css="SESSION_ID">
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
</style>
<div data-impeccable-variant="1">
<!-- variant 1: full element replacement -->
</div>
@@ -123,17 +125,9 @@ If `wrap` fails, fall back to manual grep + edit.
</div>
```
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should.
The first variant should NOT have `style="display: none"` (it should be visible by default). All others should. If variants only use inline styles and no scoped CSS, omit the `<style>` tag entirely.
6. **Write scoped CSS** if the variants need styles beyond inline:
```css
/* impeccable-variants-css-start SESSION_ID */
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
/* impeccable-variants-css-end SESSION_ID */
```
**IMPORTANT**: Write all variants in ONE edit call, not one per variant. This saves multiple round-trips and the browser's MutationObserver will pick up all variants at once.
**IMPORTANT**: Write CSS and all variants in ONE edit call. The browser's MutationObserver picks up everything at once.
### Step 3: Signal completion
@@ -147,29 +141,26 @@ The file path should be relative to the project root (e.g., `public/index.html`,
## Handle Accept
The event contains: `{id, variantId}`.
The event contains: `{id, variantId, _acceptResult}`.
The user accepted a specific variant. For v1 (inspection mode):
1. Read the accepted variant's HTML from the source (the content inside `[data-impeccable-variant="VARIANT_ID"]`).
2. Present the variant code to the user in the conversation.
3. Clean up the source: remove the entire variant wrapper (everything between `impeccable-variants-start` and `impeccable-variants-end` markers), and restore the original element.
4. Remove any scoped CSS blocks (between `impeccable-variants-css-start` and `impeccable-variants-css-end` markers).
5. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to handle the file operation deterministically. The browser has already updated the DOM visually (the user is unblocked).
Check `_acceptResult`:
- If `handled` is true and `carbonize` is false: **no work needed**. Re-poll immediately.
- If `handled` is true and `carbonize` is true: the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start`/`impeccable-carbonize-end` comments. Spawn a **background agent** to:
1. Find the carbonize markers in the file
2. Move the CSS rules into the project's proper stylesheet(s)
3. Rewrite `@scope` selectors to use the element's real classes instead of `[data-impeccable-variant]`
4. Remove any helper classes/attributes (e.g. `data-impeccable-variant`) from the accepted HTML
5. Delete the carbonize markers and inline `<style>` block
Then re-poll immediately (do not wait for the background agent).
- If `handled` is false: fall back to manual cleanup (read file, find markers, edit).
## Handle Discard
The event contains: `{id}`.
The event contains: `{id, _acceptResult}`.
1. Remove the variant wrapper from the source file.
2. Restore the original element (the content inside `[data-impeccable-variant="original"]`).
3. Remove any scoped CSS blocks for this session.
4. Reply:
```bash
node {{scripts_path}}/live-poll.mjs --reply SESSION_ID done
```
The poll script already ran `live-accept.mjs` to restore the original and remove all variant markers. The browser has already updated the DOM visually. **No work needed.** Re-poll immediately.
## Stopping Live Mode
@@ -188,7 +179,8 @@ When the loop ends:
1. **Remove the injected script tag** from the source file. Delete everything between `<!-- impeccable-live-start -->` and `<!-- impeccable-live-end -->` (inclusive). Use the appropriate comment syntax for the framework.
2. **Remove any leftover variant wrappers** (search for `impeccable-variants-start` markers and clean up).
3. **Stop the server**:
3. **Remove any leftover carbonize blocks** (search for `impeccable-carbonize-start` markers and clean up).
4. **Stop the server**:
```bash
node {{scripts_path}}/live-server.mjs stop
```
@@ -0,0 +1,354 @@
/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
if (!found) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
} else {
const result = handleAccept(id, variantNum, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, ...result }));
}
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const indent = lines[block.start].match(/^(\s*)/)[1];
// De-indent the original content back to the marker's indentation level
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, block.start),
...restored,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
function handleAccept(id, variantNum, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const indent = lines[block.start].match(/^(\s*)/)[1];
const commentSyntax = detectCommentSyntax(targetFile);
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
// Build the replacement
const restored = deindentContent(variantContent, indent);
const replacement = [];
if (cssContent) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
replacement.push(indent + '<style data-impeccable-css="' + id + '">');
// Re-indent CSS content to match
for (const cssLine of cssContent) {
replacement.push(indent + cssLine.trimStart());
}
replacement.push(indent + '</style>');
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
const newLines = [
...lines.slice(0, block.start),
...replacement,
...lines.slice(block.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return { carbonize: needsCarbonize };
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end } : null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="original">
}
if (inOriginal) {
// Count div opens/closes to find the matching </div>
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // this is the closing </div> of the original wrapper
content.push(line);
}
}
return content;
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
continue; // skip the opening <div data-impeccable-variant="N">
}
if (inVariant) {
const opens = (line.match(/<div[\s>]/g) || []).length;
const closes = (line.match(/<\/div\s*>/g) || []).length;
depth += opens - closes;
if (depth <= 0) break; // closing </div> of the variant wrapper
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
if (line.trimStart().startsWith('</style>')) break;
content.push(line);
}
}
return content.length > 0 ? content : null;
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
function findSessionFile(id, cwd) {
const marker = 'impeccable-variants-start ' + id;
const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.'];
const seen = new Set();
for (const dir of searchDirs) {
const absDir = path.join(cwd, dir);
if (!fs.existsSync(absDir)) continue;
const result = searchDir(absDir, marker, seen, 0);
if (result) {
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
}
return null;
}
function searchDir(dir, query, seen, depth) {
if (depth > 5) return null;
let realDir;
try { realDir = fs.realpathSync(dir); } catch { return null; }
if (seen.has(realDir)) return null;
seen.add(realDir);
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
catch { return null; }
for (const entry of entries) {
if (!entry.isFile()) continue;
if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue;
const filePath = path.join(dir, entry.name);
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
} catch { /* skip */ }
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (['node_modules', '.git', 'dist', 'build'].includes(entry.name)) continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
if (result) return result;
}
return null;
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax };
@@ -894,29 +894,16 @@
if (state === 'IDLE') state = 'PICKING';
break;
case 'done':
if (state === 'SAVING') {
state = 'CONFIRMED';
updateBarContent('confirmed');
setTimeout(() => {
hideBar();
hideHighlight();
stopScrollTracking();
if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
clearSession();
selectedElement = null;
currentSessionId = null;
selectedAction = 'impeccable';
state = 'PICKING';
}, 1800);
return;
}
// 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;
}
state = 'CYCLING';
updateBarContent('cycling');
if (state === 'GENERATING') {
state = 'CYCLING';
updateBarContent('cycling');
}
break;
case 'error':
console.error('[impeccable] Error:', msg.message);
@@ -1074,16 +1061,37 @@
if (!currentSessionId || arrivedVariants === 0) return;
sendEvent({ type: 'accept', id: currentSessionId, variantId: String(visibleVariant) });
markSessionHandled();
state = 'SAVING';
updateBarContent('saving');
// Don't cleanup yet — wait for the "done" WS message to show confirmation
// 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();
// Discard dismisses immediately (no "Applying" state, the agent just cleans up)
// Instant DOM restore + fire-and-forget (script handles file cleanup)
cleanup();
}
@@ -8,9 +8,11 @@
* npx impeccable poll --reply <id> error "msg" # Reply with error
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json');
@@ -110,6 +112,25 @@ Options:
}
const event = await res.json();
// Auto-handle accept/discard via deterministic script
if (event.type === 'accept' || event.type === 'discard') {
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const acceptScript = path.join(__dirname, 'live-accept.mjs');
const scriptArgs = event.type === 'discard'
? ['--id', event.id, '--discard']
: ['--id', event.id, '--variant', event.variantId];
try {
const out = execSync(
`node "${acceptScript}" ${scriptArgs.join(' ')}`,
{ encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 }
);
event._acceptResult = JSON.parse(out.trim());
} catch (err) {
event._acceptResult = { handled: false, error: err.message };
}
}
// Print the event as JSON — the agent reads this from stdout
console.log(JSON.stringify(event));
} catch (err) {
@@ -14,6 +14,7 @@
import http from 'node:http';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
@@ -364,10 +365,11 @@ if (args.includes('--help') || args.includes('-h')) {
Start the live variant mode server (zero dependencies).
Commands:
(default) Start the server
(default) Start the server (foreground)
stop Stop a running server
Options:
--background Start detached, print connection JSON to stdout, then exit
--port=PORT Use a specific port (default: auto-detect starting at 8400)
--help Show this help
@@ -390,6 +392,35 @@ if (args.includes('stop')) {
process.exit(0);
}
// --background: spawn a detached child server, wait for it to be ready,
// print the connection JSON, then exit. This keeps the startup command
// simple (no shell backgrounding or chained commands).
if (args.includes('--background')) {
const childArgs = args.filter(a => a !== '--background');
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
detached: true,
stdio: 'ignore',
cwd: process.cwd(),
});
child.unref();
// Poll for the PID file (the child writes it once the HTTP server is listening).
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));
if (info.pid !== process.pid) {
// Output JSON so the agent can read port + token from stdout.
console.log(JSON.stringify(info));
process.exit(0);
}
} catch { /* not ready yet */ }
await new Promise(r => setTimeout(r, 200));
}
console.error('Timed out waiting for live server to start.');
process.exit(1);
}
// Check for existing session
try {
const existing = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8'));