diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 87fd62d13..fa37cfbdf 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -12,12 +12,12 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "3.0.1", + "version": "3.0.2", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" }, - "source": "./", + "source": "./plugin", "category": "design", "homepage": "https://impeccable.style", "tags": ["design", "frontend", "ui", "ux", "skills", "commands"] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3d9b26d9d..7155a776c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,12 +1,12 @@ { "name": "impeccable", "description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.", - "version": "3.0.1", + "version": "3.0.2", "author": { "name": "Paul Bakaus", "email": "paul@paulbakaus.com" }, "homepage": "https://impeccable.style", "repository": "https://github.com/pbakaus/impeccable", - "skills": "./.claude/skills" + "skills": "./.claude/skills/" } diff --git a/.claude/agents/anti-patterns.md b/.claude/agents/anti-patterns.md deleted file mode 100644 index 75528a381..000000000 --- a/.claude/agents/anti-patterns.md +++ /dev/null @@ -1,257 +0,0 @@ ---- -name: anti-patterns -description: Use when adding, modifying, or debugging an anti-pattern detection rule in this repo. Walks through the TDD recipe, the rule schema, all five plug-in points, jsdom constraints, and the post-implementation checklist. Trigger this for any work touching src/detect-antipatterns.mjs, tests/fixtures/antipatterns/, or extension/detector/. -tools: Read, Edit, Write, Glob, Grep, Bash, mcp__claude-in-chrome__navigate, mcp__claude-in-chrome__javascript_tool, mcp__claude-in-chrome__tabs_context_mcp, mcp__claude-in-chrome__tabs_create_mcp ---- - -# Anti-Pattern Engine Maintenance - -This agent handles every step of adding or modifying an anti-pattern detection rule in the impeccable repo. The rule engine is wired into many places — tests, browser bundle, extension detector, extension panel JSON, homepage count, and the skill content — and missing a step causes silent drift between them. - -## The five things that need to stay in sync - -When you add a rule, all of these update or get regenerated: - -| Where | What | How it stays in sync | -|---|---|---| -| `src/detect-antipatterns.mjs` `ANTIPATTERNS` | Rule metadata (id, category, name, description, skillSection, skillGuideline) and the detection logic (`checkXxx`) | **Hand-edited.** Source of truth. | -| `src/detect-antipatterns-browser.js` | Browser-bundled engine for the public site overlay | Generated by `bun run build:browser` | -| `extension/detector/detect.js` | Browser-bundled engine for the Chrome extension | Generated by `bun run build:extension` | -| `extension/detector/antipatterns.json` | Rule list (id, name, category, description) for the extension's devtools panel — drives rule toggles UI | Generated by `bun run build:extension` | -| `public/js/generated/counts.js` | `DETECTION_COUNT` integer for homepage display | Generated by `bun run build` | -| `source/skills/impeccable/SKILL.md` and `reference/*.md` | Design guidance that a human or LLM reads. Can reference anti-patterns in its own voice. | **Hand-edited**, alongside the rule. Drift is a code-review concern, not a programmatic one. | - -The CLI (`bin/cli.js`) imports `ANTIPATTERNS` directly from `src/detect-antipatterns.mjs` — no separate sync needed. - -## Rule schema - -Each entry in the `ANTIPATTERNS` array (around src/detect-antipatterns.mjs:77) looks like this: - -```js -{ - id: 'icon-tile-stack', // kebab-case, unique, stable - category: 'slop', // 'slop' or 'quality' (see below) - name: 'Icon tile stacked above heading', // human-readable, used in extension UI - description: // 1–2 sentences. Used in CLI output, extension tooltips, web overlay labels - 'A small rounded-square icon container above a heading is the universal AI feature-card template — every generator outputs this exact shape. Try a side-by-side icon and heading, or let the icon sit in flow without its own container.', - skillSection: 'Typography', // OPTIONAL. The logical skill section this rule maps to; used for /docs/impeccable deep-links. - skillGuideline: 'large icons with rounded corners above every heading', // OPTIONAL. Canonical short phrasing for the rule; used in CLI output and as a linkable fragment. -} -``` - -### Categories - -- **`slop`** = "AI tells". Patterns that scream *AI generated this*. Things like purple gradients, gradient text, dark glow accents, thick side borders, icon-tile-stacks. Flagging these is about taste and freshness, not correctness. -- **`quality`** = real design or accessibility issues regardless of who wrote the code. WCAG contrast, line length, padding, line height, justified text, skipped headings, etc. - -If you're not sure, ask: *"would a human designer who's careful and tasteful still ship this?"* If no, it's `quality`. If they would (because it works fine, it just looks templated), it's `slop`. - -### `skillSection` values - -The value is used by `scripts/build-sub-pages.js` to build deep links into the impeccable docs page. Use one of the logical sections the skill groups rules under (e.g. `Typography`, `Color`, `Layout`, `Motion`, `Visual Details`). If the section you pick matches an `### Heading` somewhere in the skill body, the deep link will land precisely; otherwise it falls back to the section's top. Omit entirely for rules that don't have a natural home in the skill. - -### `skillGuideline` phrasing - -Canonical short phrasing for the rule (3–6 words). Used as the CLI output label when `npx impeccable detect` reports a violation, and as human-readable text in the extension's devtools panel. The skill's prose may or may not echo this phrasing verbatim — the skill is the design-guidance document, not a rule manifest. - -Examples: `'AI color palette'`, `'large icons with rounded corners above every heading'`, `'WCAG AA contrast'`. - -Omit if the rule doesn't need a short label (rare — only niche a11y-only rules). - -## The TDD recipe (always do it in this order) - -This order is non-negotiable. Fixture and failing test before implementation. The full suite must run between the rule going in and you committing. - -### 1. Write the fixture (two-column convention) - -A single HTML file at `tests/fixtures/antipatterns/{rule-id}.html` with two columns: left = should-flag, right = should-pass. Each test case carries a unique heading text so the test can match snippets back to expectations. - -Convention skeleton: - -```html - - - - - - -
-
-

Should flag

- -
-
-

Should pass

- -
-
- - - -``` - -The script tag at the bottom is critical — it lets you load the fixture in the browser via `http://localhost:3000/fixtures/antipatterns/{rule-id}.html` (served by `server/index.js:62` route for `/fixtures/*`). - -**Should-pass cases must cover the false-positive shapes you can think of in advance.** A good fixture has 5+ pass cases. The icon-tile-stack fixture covers: round avatar, wide thumbnail, side-by-side, no-icon, too-tiny, too-huge. - -### 2. Write the failing test - -Add to `tests/detect-antipatterns-fixtures.test.mjs` in its own `describe` block. Use the snippet-substring matching pattern — the test parses heading text out of each finding's snippet and asserts membership against expected lists: - -```js -describe('detectHtml — {rule-id}', () => { - const SHOULD_FLAG = ['Heading One', 'Heading Two', /* ... */]; - const SHOULD_PASS = ['Pass Heading One', /* ... */]; - - it('{rule-id}: flags only the should-flag column', async () => { - const f = await detectHtml(path.join(FIXTURES, '{rule-id}.html')); - const flagged = new Set(); - for (const r of f) { - if (r.antipattern !== '{rule-id}') continue; - const m = (r.snippet || '').match(/"([^"]+)"/); - if (m) flagged.add(m[1]); - } - for (const text of SHOULD_FLAG) { - assert.ok(flagged.has(text), `expected "${text}" to be flagged`); - } - for (const text of SHOULD_PASS) { - assert.ok(!flagged.has(text), `"${text}" should NOT be flagged`); - } - }); -}); -``` - -For this to work, the rule's snippet **must include the heading text in quotes**. See "Snippet conventions" below. - -Run `node --test tests/detect-antipatterns-fixtures.test.mjs` and **watch it fail**. If it doesn't fail, your test is wrong. - -### 3. Add the rule definition - -Add a new entry to the `ANTIPATTERNS` array in `src/detect-antipatterns.mjs`. Place it in the right category section (slop or quality). Fill in all fields including `skillSection` and `skillGuideline`. - -### 4. Implement the pure check function - -Add a `checkXxx(opts)` function alongside the others (`checkColors`, `checkBorders`, `checkMotion`, `checkGlow`, `checkIconTile`, etc.). The pure function takes a plain options object — no DOM access — and returns an array of `{ id, snippet }`. This makes it testable and reusable across the browser/Node adapters. - -Example shape (see `checkIconTile` in src/detect-antipatterns.mjs for a real one): - -```js -function checkXxx(opts) { - const { tag, /* whatever fields the rule needs */ } = opts; - if (SAFE_TAGS.has(tag)) return []; - // ... your detection logic ... - if (matches) { - return [{ id: 'rule-id', snippet: `... "${headingText}"` }]; - } - return []; -} -``` - -### 5. Add the two adapters - -Two adapters wrap the pure function with environment-specific input gathering: - -- **`checkElementXxxDOM(el)`** — for the browser. Uses `getComputedStyle(el)` and `el.getBoundingClientRect()`. -- **`checkElementXxx(el, tag, window)`** — for jsdom (Node). Uses `window.getComputedStyle(el)` and **must read explicit pixel dimensions from `parseFloat(style.width)`** instead of bounding rects, because **jsdom does not lay out** — `getBoundingClientRect()` returns 0×0 for everything. - -If your rule needs vertical positioning info (e.g. "icon must be above heading"), that check is browser-only — gate it behind `if (headingTop && siblingBottom)` so the Node path skips it. The structural checks alone (sizes, sibling identity, classes) are enough for the fixture. - -### 6. Wire into both element-iteration loops - -Two loops iterate every element on the page. You need to add your DOM-adapter call to **both**: - -- **Browser loop** at src/detect-antipatterns.mjs:1837 (`for (const el of document.querySelectorAll('*'))` with the `findings` spread). Add a line like: - ```js - ...checkElementXxxDOM(el).map(f => ({ type: f.id, detail: f.snippet })), - ``` -- **Node (jsdom) loop** at src/detect-antipatterns.mjs:2058 (in `detectHtml`). Add a block like: - ```js - for (const f of checkElementXxx(el, tag, window)) { - findings.push(finding(f.id, filePath, f.snippet)); - } - ``` - -Forgetting one of these is the most common mistake — the test passes but the live page doesn't show anything (or vice versa). - -### 7. Decide whether the skill needs an update - -If the rule introduces a new design concept not already covered by the impeccable skill, update `source/skills/impeccable/SKILL.md` (or the appropriate register file in `reference/editorial.md` / `reference/product.md`) to teach the concept. The skill is a design-guidance document — it doesn't need to echo every rule verbatim, and one skill line can cover multiple engine rules. Only add prose if there's a real gap in the guidance. - -### 8. Run the build (regenerates everything) - -```bash -bun run build && bun run build:browser && bun run build:extension -``` - -This regenerates: -- `src/detect-antipatterns-browser.js` (public-site detector) -- `extension/detector/detect.js` (extension detector) -- `extension/detector/antipatterns.json` (extension rule list, includes description) -- `public/js/generated/counts.js` (DETECTION_COUNT) - -### 9. Run the test suite - -```bash -bun run test -``` - -166 unit tests + N fixture tests, including your new one. All should be green. - -### 10. Verify on a live page in the browser - -Don't skip this. The jsdom path uses `parseFloat(style.width)` and the browser path uses `getBoundingClientRect()` — they can disagree. The fixture test catches one path; manual browser verification catches the other. - -``` -http://localhost:3000/fixtures/antipatterns/{rule-id}.html -http://localhost:3000/antipattern-examples/{your-example}.html (if relevant) -http://localhost:3000/ (no false positives on real pages) -``` - -Use the chrome MCP tools (`mcp__claude-in-chrome__navigate` + `mcp__claude-in-chrome__javascript_tool`) to inject `window.impeccableScan()` and read `.impeccable-overlay` / `.impeccable-label` from the DOM to verify. Don't try to screenshot — the overlays are decorative; read them programmatically. - -## Snippet conventions - -The fixture-test convention extracts the heading text from a finding's snippet using regex `/"([^"]+)"/` — so **wrap the identifying heading text in straight double quotes** in your snippet. Examples: - -- `'80x80px icon tile above h3 "Lightning Fast"'` -- `'4.5:1 (need 4.5:1) — text #808080 on #3b82f6'` ← uses element identifiers instead, since this rule isn't anchored to a heading - -If your rule isn't naturally anchored to a heading, pick another stable identifier (a class name, the parent element's text, etc.) and document the test pattern in the test itself. - -## jsdom constraints (the most common gotcha) - -- **No layout.** `getBoundingClientRect()` returns `0×0` always. Read `parseFloat(style.width)` and `parseFloat(style.height)` instead — jsdom does honor explicit pixel widths in ` +
+ +
+
+ +
+
+ +
+``` + +**Each variant div contains exactly one top-level element — the full replacement for the original.** Use the same tag as the original (e.g. `
` if the user picked a `
`). Loose siblings (heading + paragraph + div as direct children of the variant div) break the outline tracking and the accept flow, which both assume one child. + +The first variant has no `display: none` (visible by default). All others do. If variants use only inline styles and no scoped CSS, omit the `' : '')); + if (paramValues && Object.keys(paramValues).length > 0) { + // Preserve the user's knob positions for the carbonize-cleanup agent + // to bake into the final CSS when it collapses scoped rules. + replacement.push(indent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close); + } + replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close); + } + + // Keep the `@scope ([data-impeccable-variant="N"])` selectors in the + // carbonize CSS block working visually by re-wrapping the accepted content + // in a data-impeccable-variant="N" div with `display: contents` (so layout + // isn't affected). The carbonize agent strips this attribute + wrapper when + // it moves the CSS to a proper stylesheet. + // + // Style attribute syntax has to follow the host file's flavor — JSX files + // need the object form, otherwise React 19 throws "Failed to set indexed + // property [0] on CSSStyleDeclaration" while parsing the string char-by-char. + if (cssContent) { + const isJsx = commentSyntax.open === '{/*'; + const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; + replacement.push(indent + '
'); + replacement.push(...restored); + replacement.push(indent + '
'); + } else { + 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; +} + +/** + * Join wrapper lines into a single string with `` to close on) + * - Same-line `` blocks + * - Multi-line `` blocks + */ +function stripStyleAndJoin(lines, block) { + const out = []; + let inStyle = false; + for (let i = block.start; i <= block.end; i++) { + let line = lines[i]; + + if (!inStyle) { + // Strip any complete . + const closeIdx = line.search(/<\/style\s*>/); + if (closeIdx !== -1) { + inStyle = false; + out.push(line.slice(closeIdx).replace(/<\/style\s*>/, '')); + } + // else: skip line entirely + } + } + return out.join('\n'); +} + +/** + * Find the inner content of `` inside `text`, + * handling nested same-tag elements via depth counting. `attrMatch` is a + * regex source fragment that must appear inside the opener tag. + * Returns the inner string (may be empty), or null if not found. + */ +function extractInnerByAttr(text, attrMatch) { + const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>'); + const openMatch = text.match(openerRe); + if (!openMatch) return null; + + const tagName = openMatch[1]; + const innerStart = openMatch.index + openMatch[0].length; + + // Match any opener or closer of this tag name after innerStart. + // (Does not match self-closing , which doesn't contribute to depth.) + const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g'); + tagRe.lastIndex = innerStart; + + let depth = 1; + let m; + while ((m = tagRe.exec(text))) { + const isClose = m[0].startsWith('$/.test(m[0]); + if (isClose) { + depth--; + if (depth === 0) return text.slice(innerStart, m.index); + } else if (!isSelfClose) { + depth++; + } + } + return null; +} + +/** + * Extract the original element content from within the variant wrapper. + * Returns an array of lines. + */ +function extractOriginal(lines, block) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"'); + if (inner === null) return []; + return inner.split('\n'); +} + +/** + * 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) { + const text = stripStyleAndJoin(lines, block); + const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"'); + if (inner === null) return null; + const result = inner.split('\n'); + // Collapse a lone empty leading/trailing line (common after string splice). + while (result.length > 1 && result[0].trim() === '') result.shift(); + while (result.length > 1 && result[result.length - 1].trim() === '') result.pop(); + return result.length > 0 ? result : null; +} + +/** + * Extract the colocated ` — return the inner content. + * 3. Multi-line: `` on a later line — return + * the lines between them. + */ +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)) { + // Self-closing: nothing to carbonize. + if (/]*\/\s*>/.test(line)) return null; + // Same-line open + close: extract inner text. + const sameLine = line.match(/]*>([\s\S]*?)<\/style\s*>/); + if (sameLine) { + const inner = sameLine[1]; + return inner.length > 0 ? inner.split('\n') : null; + } + inStyle = true; + continue; // skip the anywhere on the line — JSX template-literal closes + // (`}`) put the close mid-line, and we don't want to absorb the + // template-literal punctuation as CSS content. + const closeIdx = line.indexOf(''); + if (closeIdx !== -1) 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: '' }; +} + +// --------------------------------------------------------------------------- +// 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 }; diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js new file mode 100644 index 000000000..66a54435a --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-browser.js @@ -0,0 +1,4684 @@ +/** + * Impeccable Live Variant Mode — Browser Script + * + * Injected into the user's page via \n' + + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' + ); +} + +function insertTag(content, config, port) { + const block = buildTagBlock(config.commentSyntax, port); + // insertBefore: match the LAST occurrence. Anchors like `` naturally + // belong at the end, and the same literal can appear earlier in code blocks + // within rendered documentation pages. + if (config.insertBefore) { + const idx = content.lastIndexOf(config.insertBefore); + if (idx === -1) return content; + return content.slice(0, idx) + block + content.slice(idx); + } + // insertAfter: match the FIRST occurrence — typical anchors like `` or + // `` open near the top of the document. + const idx = content.indexOf(config.insertAfter); + if (idx === -1) return content; + const after = idx + config.insertAfter.length; + // Preserve a single trailing newline if the anchor didn't end with one + const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n'; + return prefix + block + content.slice(prefix.length); +} + +/** + * Remove the live script block. Matches either HTML or JSX comment markers + * regardless of config (so stale tags from a wrong config can still be cleaned). + * + * Indent-preserving: captures any whitespace immediately preceding the opener + * marker and re-emits it in place of the removed block. `insertTag` inserted + * the block *after* the original line's indent and *before* the anchor (e.g. + * ``), which moved the indent onto the opener line and left the anchor + * unindented. Replacing the whole block (plus its trailing newline) with just + * the captured indent hands the indent back to the anchor that follows. + */ +function removeTag(content, _syntax) { + const patterns = [ + /([ \t]*)[\s\S]*?[ \t]*\n/, + /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}[ \t]*\n/, + ]; + for (const pat of patterns) { + const next = content.replace(pat, '$1'); + if (next !== content) return next; + } + return content; +} + +// --------------------------------------------------------------------------- +// Content-Security-Policy meta-tag patcher +// +// When the user's HTML carries ``, +// the cross-origin load of /live.js (and the SSE/POST connection back to +// localhost:PORT) is blocked unless the CSP explicitly allows that origin. +// +// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`, +// and stash the original `content` value in a `data-impeccable-csp-original` +// attribute (base64) so revert is exact. +// +// On remove: detect the marker attribute, decode it, restore the original +// content value verbatim, drop the marker. +// +// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp, +// shared helpers) is NOT patched here — those need framework-specific config +// edits and are handled via the existing detect-csp.mjs reference output. +// Only the in-source meta-tag form gets the auto-patch. +// --------------------------------------------------------------------------- + +const CSP_MARKER_ATTR = 'data-impeccable-csp-original'; + +function findCspMetaTags(content) { + const out = []; + const tagRe = /]*?)\/?>/gis; + let m; + while ((m = tagRe.exec(content)) !== null) { + const attrs = m[1]; + if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue; + out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs }); + } + return out; +} + +function getAttr(attrs, name) { + const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i'); + const m = attrs.match(re); + return m ? { quote: m[1], value: m[2], full: m[0] } : null; +} + +function appendOriginToDirective(csp, directive, origin) { + const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i'); + const m = csp.match(re); + if (m) { + const tokens = m[4].trim().split(/\s+/); + if (tokens.includes(origin)) return csp; + return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`); + } + // Directive missing — add it. Use 'self' + origin so we don't inadvertently + // narrow the policy compared to the default-src fallback (most users with + // an explicit CSP have 'self' there). + return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`; +} + +export function patchCspMeta(content, port) { + const tags = findCspMetaTags(content); + if (tags.length === 0) return content; + const origin = `http://localhost:${port}`; + + // Walk last-to-first so prior splices don't invalidate later indices. + let result = content; + for (let i = tags.length - 1; i >= 0; i--) { + const tag = tags[i]; + const attrs = tag.attrs; + if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched + const contentAttr = getAttr(attrs, 'content'); + if (!contentAttr) continue; + + const original = contentAttr.value; + let patched = original; + patched = appendOriginToDirective(patched, 'script-src', origin); + patched = appendOriginToDirective(patched, 'connect-src', origin); + // The shader overlay during 'generating' creates a screenshot via + // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects + // those. Add `blob:` so the overlay doesn't throw a CSP violation. + patched = appendOriginToDirective(patched, 'img-src', 'blob:'); + if (patched === original) continue; + + const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`; + const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`; + const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker; + const newTag = tag.full.replace(attrs, newAttrs); + + result = result.slice(0, tag.start) + newTag + result.slice(tag.end); + } + return result; +} + +export function revertCspMeta(content) { + const tags = findCspMetaTags(content); + if (tags.length === 0) return content; + + let result = content; + for (let i = tags.length - 1; i >= 0; i--) { + const tag = tags[i]; + const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR); + if (!origAttr) continue; + const contentAttr = getAttr(tag.attrs, 'content'); + if (!contentAttr) continue; + + let originalValue; + try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); } + catch { continue; } + + const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`; + let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr); + // Drop the marker attribute and any single space immediately preceding it. + newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), ''); + const newTag = tag.full.replace(tag.attrs, newAttrs); + + result = result.slice(0, tag.start) + newTag + result.slice(tag.end); + } + return result; +} + +// --------------------------------------------------------------------------- +// Auto-execute +// --------------------------------------------------------------------------- + +const _running = process.argv[1]; +if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) { + injectCli(); +} + +export { insertTag, removeTag, validateConfig, buildTagBlock }; +// patchCspMeta + revertCspMeta are exported above where they're defined. diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs new file mode 100644 index 000000000..5cece1a43 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-poll.mjs @@ -0,0 +1,187 @@ +/** + * CLI client for the live variant mode poll/reply protocol. + * + * Usage: + * npx impeccable poll # Block until browser event, print JSON + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly + * npx impeccable poll --reply done # Reply "done" to event + * npx impeccable poll --reply 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'; + +// Node's built-in fetch (undici under the hood) enforces a 300s headers +// timeout that can't be lowered per-request. We cap each request below +// that ceiling and loop in `pollOnce` to synthesize a long poll without +// depending on the standalone undici package. +const PER_REQUEST_TIMEOUT_MS = 270_000; + +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); + +function readServerInfo() { + try { + return JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + } catch { + console.error('No running live server found. Start one with: npx impeccable live'); + process.exit(1); + } +} + +export async function pollCli() { + const args = process.argv.slice(2); + + if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: impeccable poll [options] + +Wait for a browser event from the live variant server, or reply to one. + +Modes: + poll Block until a browser event arrives, print JSON + poll --reply done Reply "done" to event + poll --reply error "msg" Reply with an error message + +Options: + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn + --help Show this help message`); + process.exit(0); + } + + const info = readServerInfo(); + const base = `http://localhost:${info.port}`; + + // Reply mode: npx impeccable poll --reply [--file path] [message] + const replyIdx = args.indexOf('--reply'); + if (replyIdx !== -1) { + const id = args[replyIdx + 1]; + const status = args[replyIdx + 2] || 'done'; + const fileIdx = args.indexOf('--file'); + const filePath = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined; + // Message is any remaining positional arg that isn't a flag + const message = args.find((a, i) => i > replyIdx + 2 && !a.startsWith('--') && i !== fileIdx + 1) || undefined; + + if (!id) { + console.error('Usage: npx impeccable poll --reply [--file path] [message]'); + process.exit(1); + } + + try { + const res = await fetch(`${base}/poll`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + token: info.token, + id, + type: status, + message, + file: filePath, + }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + console.error(`Reply failed (${res.status}):`, body.error || res.statusText); + process.exit(1); + } + + // Success — silent exit (agent doesn't need output for replies) + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Reply failed:', err.message); + } + process.exit(1); + } + return; + } + + // Poll mode: block until browser event. Default 10 min. Node's built-in + // fetch enforces a 300s headers timeout, so we loop in slices under that + // ceiling and keep re-polling until we get a real event or the user's + // total timeout runs out. + const timeoutArg = args.find(a => a.startsWith('--timeout=')); + const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; + + const deadline = Date.now() + totalTimeout; + let event; + try { + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + event = { type: 'timeout' }; + break; + } + const slice = Math.min(remaining, PER_REQUEST_TIMEOUT_MS); + const res = await fetch(`${base}/poll?token=${info.token}&timeout=${slice}`); + + if (res.status === 401) { + console.error('Authentication failed. The server token may have changed.'); + console.error('Try restarting: npx impeccable live stop && npx impeccable live'); + process.exit(1); + } + + if (!res.ok) { + console.error(`Poll failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const next = await res.json(); + // Server-side timeout means no browser event arrived in this slice. + // Loop and re-poll until we get a real event or we hit the user's + // total deadline. + if (next?.type === 'timeout' && Date.now() < deadline) continue; + event = next; + break; + } + + // 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]; + if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) { + // Pass through a JSON blob; the shell-safe wrap uses single quotes because + // values are finite {id, number|string|boolean} pairs from a validated payload. + scriptArgs.push('--param-values', `'${JSON.stringify(event.paramValues).replace(/'/g, "'\\''")}'`); + } + 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 }; + } + } + + // Second signal path: stderr banner in case the agent parses stdout + // JSON but skips nested fields. One line is enough — the full checklist + // is in reference/live.md. + if (event._acceptResult?.carbonize === true) { + process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. See reference/live.md "Required after accept".\n\n'); + } + + // Print the event as JSON — the agent reads this from stdout + console.log(JSON.stringify(event)); + } catch (err) { + if (err.cause?.code === 'ECONNREFUSED') { + console.error('Live server not running. Start one with: npx impeccable live'); + } else { + console.error('Poll failed:', err.message); + } + process.exit(1); + } +} + +// Auto-execute when run directly +const _running = process.argv[1]; +if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) { + pollCli(); +} diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs new file mode 100644 index 000000000..3d608e3c0 --- /dev/null +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -0,0 +1,679 @@ +#!/usr/bin/env node +/** + * Live variant mode server (self-contained, zero dependencies). + * + * Serves the browser script (/live.js), the detection overlay (/detect.js), + * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for + * browser→server events. Agent communicates via HTTP long-poll (/poll). + * + * Usage: + * node /live-server.mjs # start + * node /live-server.mjs stop # stop + remove injected live.js tag + * node /live-server.mjs stop --keep-inject # stop only + * node /live-server.mjs --help + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { spawn, execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import net from 'node:net'; +import { fileURLToPath } from 'node:url'; +import { parseDesignMd } from './design-parser.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// PID file in the project root so both the server and agent can find it +// predictably (os.tmpdir() varies across platforms). +const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); +const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway +const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s + +// --------------------------------------------------------------------------- +// Port detection +// --------------------------------------------------------------------------- + +async function findOpenPort(start = 8400) { + return new Promise((resolve) => { + const srv = net.createServer(); + srv.listen(start, '127.0.0.1', () => { + const port = srv.address().port; + srv.close(() => resolve(port)); + }); + srv.on('error', () => resolve(findOpenPort(start + 1))); + }); +} + +// --------------------------------------------------------------------------- +// Session state +// --------------------------------------------------------------------------- + +const state = { + token: null, + port: null, + sseClients: new Set(), // SSE response objects (server→browser push) + pendingEvents: [], // browser events waiting for agent poll + pendingPolls: [], // agent poll callbacks waiting for browser events + exitTimer: null, + sessionDir: null, // per-session tmp dir for annotation screenshots +}; + +// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB; +// cap at 10 MB to guard against runaway writes from a misbehaving client. +const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024; + +function enqueueEvent(event) { + if (state.pendingPolls.length > 0) { + state.pendingPolls.shift()(event); + } else { + state.pendingEvents.push(event); + } +} + +/** Push a message to all connected SSE clients. */ +function broadcast(msg) { + const data = 'data: ' + JSON.stringify(msg) + '\n\n'; + for (const res of state.sseClients) { + try { res.write(data); } catch { /* client gone */ } + } +} + +// --------------------------------------------------------------------------- +// Load scripts +// --------------------------------------------------------------------------- + +function loadBrowserScripts() { + // Detection script: look relative to the skill scripts dir, then fall back + // to the npm package location (src/detect-antipatterns-browser.js). + // This one IS cached — detect.js rarely changes during a session. + const detectPaths = [ + path.join(__dirname, '..', '..', '..', '..', 'src', 'detect-antipatterns-browser.js'), + path.join(process.cwd(), 'node_modules', 'impeccable', 'src', 'detect-antipatterns-browser.js'), + ]; + let detectScript = ''; + for (const p of detectPaths) { + try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ } + } + + // live-browser.js: DO NOT cache. Return the path so the /live.js handler + // can re-read on every request. Editing the browser script during iteration + // should land on the next tab reload, not require a server restart. + const livePath = path.join(__dirname, 'live-browser.js'); + if (!fs.existsSync(livePath)) { + process.stderr.write('Error: live-browser.js not found at ' + livePath + '\n'); + process.exit(1); + } + + return { detectScript, livePath }; +} + +function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. + try { + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); + return true; + } catch { return false; } +} + +function statOrNull(filePath) { + try { return fs.statSync(filePath); } catch { return null; } +} + +// --------------------------------------------------------------------------- +// Validation (inline — no external import needed for self-contained script) +// --------------------------------------------------------------------------- + +const VISUAL_ACTIONS = [ + 'impeccable', 'bolder', 'quieter', 'distill', 'polish', 'typeset', + 'colorize', 'layout', 'adapt', 'animate', 'delight', 'overdrive', +]; + +function validateEvent(msg) { + if (!msg || typeof msg !== 'object' || !msg.type) return 'Missing or invalid message'; + switch (msg.type) { + case 'generate': + if (!msg.id || typeof msg.id !== 'string') return 'generate: missing id'; + if (!msg.action || !VISUAL_ACTIONS.includes(msg.action)) return 'generate: invalid action'; + if (!Number.isInteger(msg.count) || msg.count < 1 || msg.count > 8) return 'generate: count must be 1-8'; + if (!msg.element || !msg.element.outerHTML) return 'generate: missing element context'; + // Optional annotation fields (all-or-nothing: if any present, all must be well-formed). + if (msg.screenshotPath !== undefined && typeof msg.screenshotPath !== 'string') return 'generate: screenshotPath must be string'; + if (msg.comments !== undefined && !Array.isArray(msg.comments)) return 'generate: comments must be array'; + if (msg.strokes !== undefined && !Array.isArray(msg.strokes)) return 'generate: strokes must be array'; + return null; + case 'accept': + if (!msg.id) return 'accept: missing id'; + if (!msg.variantId) return 'accept: missing variantId'; + if (msg.paramValues !== undefined) { + if (typeof msg.paramValues !== 'object' || msg.paramValues === null || Array.isArray(msg.paramValues)) { + return 'accept: paramValues must be an object'; + } + } + return null; + case 'discard': + return msg.id ? null : 'discard: missing id'; + case 'exit': + return null; + case 'prefetch': + if (!msg.pageUrl || typeof msg.pageUrl !== 'string') return 'prefetch: missing pageUrl'; + return null; + default: + return 'Unknown event type: ' + msg.type; + } +} + +// --------------------------------------------------------------------------- +// HTTP request handler +// --------------------------------------------------------------------------- + +function createRequestHandler({ detectScript, livePath }) { + return (req, res) => { + const url = new URL(req.url, `http://localhost:${state.port}`); + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } + + const p = url.pathname; + + // --- Scripts --- + if (p === '/live.js') { + // Re-read from disk each request so edits to live-browser.js land on + // the next tab reload. No-store headers prevent browser caching across + // sessions — during iteration, a cached old script silently breaks + // every subsequent session. + let liveScript; + try { + liveScript = fs.readFileSync(livePath, 'utf-8'); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end('Error reading live-browser.js: ' + err.message); + return; + } + const body = + `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` + + `window.__IMPECCABLE_PORT__ = ${state.port};\n` + + liveScript; + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0', + 'Pragma': 'no-cache', + }); + res.end(body); + return; + } + if (p === '/detect.js' || p === '/') { + if (!detectScript) { res.writeHead(404); res.end('Not available'); return; } + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(detectScript); + return; + } + + // --- Vendored modern-screenshot (UMD build) --- + // Lazy-loaded by live.js when the user clicks Go; exposes + // window.modernScreenshot.domToBlob(...) for capture. + if (p === '/modern-screenshot.js') { + const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js'); + try { + res.writeHead(200, { + 'Content-Type': 'application/javascript', + 'Cache-Control': 'public, max-age=31536000, immutable', + }); + res.end(fs.readFileSync(vendorPath)); + } catch { + res.writeHead(404); res.end('Vendor script not found'); + } + return; + } + + // --- Annotation upload (browser → server, raw PNG body) --- + // Client generates the eventId, POSTs the PNG, then POSTs the generate + // event with screenshotPath already set. Keeps bytes out of the SSE/poll + // bridge and preserves the "one shot from the user's POV" UX. + if (p === '/annotation' && req.method === 'POST') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const eventId = url.searchParams.get('eventId'); + if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid eventId' })); + return; + } + if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') { + res.writeHead(415, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Content-Type must be image/png' })); + return; + } + if (!state.sessionDir) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session dir unavailable' })); + return; + } + const chunks = []; + let total = 0; + let aborted = false; + req.on('data', (c) => { + if (aborted) return; + total += c.length; + if (total > MAX_ANNOTATION_BYTES) { + aborted = true; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + req.destroy(); + return; + } + chunks.push(c); + }); + req.on('end', () => { + if (aborted) return; + const absPath = path.join(state.sessionDir, eventId + '.png'); + try { + fs.writeFileSync(absPath, Buffer.concat(chunks)); + } catch (err) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Write failed: ' + err.message })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: absPath })); + }); + req.on('error', () => { + if (!aborted) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Upload failed' })); + } + }); + return; + } + + // --- Health --- + if (p === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', port: state.port, mode: 'variant', + hasProjectContext: hasProjectContext(), + connectedClients: state.sseClients.size, + })); + return; + } + + // --- Design system (unified v2 response) + raw --- + // /design-system.json returns both parsed DESIGN.md and DESIGN.json + // sidecar when present. Panel merges them: + // { present, parsed, sidecar, hasMd, hasSidecar, + // mdNewerThanJson, parseError?, sidecarError? } + // - parsed: output of parseDesignMd (frontmatter + // + six canonical sections) when DESIGN.md exists. + // - sidecar: DESIGN.json contents when present. + // Expected shape: schemaVersion 2, carrying + // extensions + components + narrative. + // /design-system/raw returns DESIGN.md markdown verbatim + if (p === '/design-system.json' || p === '/design-system/raw') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + + const mdPath = path.join(process.cwd(), 'DESIGN.md'); + const jsonPath = path.join(process.cwd(), 'DESIGN.json'); + const mdStat = statOrNull(mdPath); + const jsonStat = statOrNull(jsonPath); + + if (p === '/design-system/raw') { + if (!mdStat) { res.writeHead(404); res.end('Not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' }); + res.end(fs.readFileSync(mdPath, 'utf-8')); + return; + } + + if (!mdStat && !jsonStat) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ present: false })); + return; + } + + const response = { + present: true, + hasMd: !!mdStat, + hasSidecar: !!jsonStat, + mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000), + }; + + if (mdStat) { + try { + response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8')); + } catch (err) { + response.parseError = err.message; + } + } + + if (jsonStat) { + try { + response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); + } catch (err) { + response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message; + } + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + return; + } + + // --- Source file (no-HMR fallback) --- + if (p === '/source') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + const filePath = url.searchParams.get('path'); + if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; } + const absPath = path.resolve(process.cwd(), filePath); + if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; } + let content; + try { content = fs.readFileSync(absPath, 'utf-8'); } + catch { res.writeHead(404); res.end('File not found'); return; } + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(content); + return; + } + + // --- SSE: server→browser push (replaces WebSocket) --- + if (p === '/events' && req.method === 'GET') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + res.write('data: ' + JSON.stringify({ + type: 'connected', + hasProjectContext: hasProjectContext(), + }) + '\n\n'); + + state.sseClients.add(res); + clearTimeout(state.exitTimer); + + // Keepalive: SSE comment every 30s prevents silent connection drops. + const heartbeat = setInterval(() => { + try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); } + }, SSE_HEARTBEAT_INTERVAL); + + req.on('close', () => { + clearInterval(heartbeat); + state.sseClients.delete(res); + if (state.sseClients.size === 0) { + clearTimeout(state.exitTimer); + state.exitTimer = setTimeout(() => { + if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' }); + }, 8000); + } + }); + return; + } + + // --- Browser→server events (replaces WebSocket messages) --- + if (p === '/events' && req.method === 'POST') { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const error = validateEvent(msg); + if (error) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error })); + return; + } + enqueueEvent(msg); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + return; + } + + // --- Stop --- + if (p === '/stop') { + const token = url.searchParams.get('token'); + if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; } + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('stopping'); + shutdown(); + return; + } + + // --- Agent poll --- + if (p === '/poll' && req.method === 'GET') { + handlePollGet(req, res, url); + return; + } + if (p === '/poll' && req.method === 'POST') { + handlePollPost(req, res); + return; + } + + res.writeHead(404); res.end('Not found'); + }; +} + +// --------------------------------------------------------------------------- +// Agent poll endpoints (unchanged from WS version) +// --------------------------------------------------------------------------- + +function handlePollGet(req, res, url) { + const token = url.searchParams.get('token'); + if (token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10); + if (state.pendingEvents.length > 0) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(state.pendingEvents.shift())); + return; + } + const timer = setTimeout(() => { + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ type: 'timeout' })); + }, timeout); + function resolve(event) { + clearTimeout(timer); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(event)); + } + state.pendingPolls.push(resolve); + req.on('close', () => { + clearTimeout(timer); + const idx = state.pendingPolls.indexOf(resolve); + if (idx !== -1) state.pendingPolls.splice(idx, 1); + }); +} + +function handlePollPost(req, res) { + let body = ''; + req.on('data', (c) => { body += c; }); + req.on('end', () => { + let msg; + try { msg = JSON.parse(body); } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON' })); + return; + } + if (msg.token !== state.token) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + // Forward the reply to the browser via SSE + broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data }); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); +} + +// --------------------------------------------------------------------------- +// Lifecycle +// --------------------------------------------------------------------------- + +let httpServer = null; + +function shutdown() { + try { fs.unlinkSync(LIVE_PID_FILE); } catch {} + if (state.sessionDir) { + try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {} + } + for (const res of state.sseClients) { try { res.end(); } catch {} } + state.sseClients.clear(); + for (const resolve of state.pendingPolls) resolve({ type: 'exit' }); + state.pendingPolls.length = 0; + if (httpServer) httpServer.close(); + process.exit(0); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); + +if (args.includes('--help') || args.includes('-h')) { + console.log(`Usage: node live-server.mjs [options] + +Start the live variant mode server (zero dependencies). + +Commands: + (default) Start the server (foreground) + stop Stop the server and remove the injected live.js script tag + stop --keep-inject Stop the server only (leave the script tag in the HTML entry) + +Options: + --background Start detached, print connection JSON to stdout, then exit + --port=PORT Use a specific port (default: auto-detect starting at 8400) + --keep-inject Only with stop: skip live-inject.mjs --remove + --help Show this help + +Endpoints: + /live.js Browser script (element picker + variant cycling) + /detect.js Detection overlay (backwards compatible) + /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js) + /annotation POST raw image/png to stage a variant screenshot + /events SSE stream (server→browser) + POST (browser→server) + /poll Long-poll for agent CLI + /source Raw source file reader (no-HMR fallback) + /health Health check`); + process.exit(0); +} + +if (args.includes('stop')) { + const keepInject = args.includes('--keep-inject'); + try { + const info = JSON.parse(fs.readFileSync(LIVE_PID_FILE, 'utf-8')); + const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`); + if (res.ok) console.log(`Stopped live server on port ${info.port}.`); + } catch { + console.log('No running live server found.'); + } + if (!keepInject) { + const injectPath = path.join(__dirname, 'live-inject.mjs'); + try { + const out = execFileSync(process.execPath, [injectPath, '--remove'], { + encoding: 'utf-8', + cwd: process.cwd(), + }); + const line = out.trim().split('\n').filter(Boolean).pop(); + if (line) { + try { + const j = JSON.parse(line); + if (j.removed === true) { + console.log(`Removed live script tag from ${j.file}.`); + } + } catch { + /* ignore non-JSON lines */ + } + } + } catch (err) { + const detail = err.stderr?.toString?.().trim?.() + || err.stdout?.toString?.().trim?.() + || err.message + || String(err); + console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`); + } + } + 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')); + try { process.kill(existing.pid, 0); + console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`); + console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop'); + process.exit(1); + } catch { fs.unlinkSync(LIVE_PID_FILE); } +} catch {} + +state.token = randomUUID(); +const portArg = args.find(a => a.startsWith('--port=')); +state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort(); +// Annotation screenshots live in the project root so the agent's Read tool +// doesn't trip a per-file permission prompt. Sessioned by token so concurrent +// projects (or quick restarts) don't collide. +const annotRoot = path.join(process.cwd(), '.impeccable-live', 'annotations'); +fs.mkdirSync(annotRoot, { recursive: true }); +state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-')); + +const { detectScript, livePath } = loadBrowserScripts(); +httpServer = http.createServer(createRequestHandler({ detectScript, livePath })); + +httpServer.listen(state.port, '127.0.0.1', () => { + fs.writeFileSync(LIVE_PID_FILE, JSON.stringify({ pid: process.pid, port: state.port, token: state.token })); + const url = `http://localhost:${state.port}`; + console.log(`\nImpeccable live server running on ${url}`); + console.log(`Token: ${state.token}\n`); + console.log(`Inject: