mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.6
parent
9b573de1fb
commit
830fe8e5fc
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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'));
|
||||
|
||||
+34
-32
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
|
||||
Reference in New Issue
Block a user