fix(live): multi-file inject, generated-file protection, and accept-flow correctness

Addresses every issue surfaced during hands-on live-mode testing.

## Injection across multi-page sites

- Config schema: `file` → `files: string[]` so multi-page static sites can
  opt into script-tag injection across every HTML entry the browser loads.
- `live-inject.mjs` loops the array, reports per-file results, and
  refuses silently with `config_invalid` if the schema is stale.
- `insertBefore` switched from first-match to last-match (lastIndexOf)
  so the anchor lands at the true close of `</body>`, not the first one
  embedded inside a `<pre><code>` documentation sample.

## Source-vs-generated detection

- New `is-generated.mjs` helper: gitignore check + generated-header
  markers. Edge-case `generatedFiles` config dropped — the two real
  signals cover every project shape we tested.
- `live-wrap.mjs` excludes generated files from auto-search and returns
  clear fallback errors: `file_is_generated`, `element_not_in_source`
  (with `generatedMatch` path), and `element_not_found`.
- `live-accept.mjs` refuses to persist into generated files; returns
  `mode: "fallback"` so the agent takes over via the Handle fallback
  flow.

## Accept correctness

- `extractVariant` / `extractOriginal` now skip `<style>` regions when
  matching markers. Previous regex substring match treated
  `@scope ([data-impeccable-variant="N"])` in CSS as the target HTML
  div, capturing garbage and producing orphan CSS that rendered as
  prose on the page.
- On accept, the chosen variant's content is wrapped in
  `<div data-impeccable-variant="N" style="display: contents">` so the
  carbonize block's `@scope` selectors keep matching. Users see the
  accepted design immediately; no pre-carbonize dead state.

## Browser-side UI

- `positionBar` gains a third case: when the selected element is taller
  than the viewport, pin the bar to a stable viewport anchor instead of
  teleporting between top and bottom as the user scrolls.
- No-HMR source-fetch path (`injectVariantsFromSource`) now calls
  `hideShaderOverlay()` on state transition to CYCLING. Previously the
  shader kept running after variants arrived via the fetch fallback.
- `pickVariantContent` helper replaces fragile `> :first-child`
  selection for outline positioning. Skips non-visual tags (style,
  script, link, meta, template) and falls back to the variant div
  itself when a variant contains multiple visual children.
- `resumeSession` re-captures and restarts the shader overlay when
  the page reloads mid-generation (Bun HTML HMR does a full reload
  and destroys the canvas).
- MutationObserver re-anchors `selectedElement` when the original
  element is detached by HMR, preventing zero-rect highlight drift.

## Skill docs

- `live.md` reframes `config.files` as "the HTML files the browser
  actually loads" and documents the regen-wipes-inject caveat for
  multi-page generator projects.
- New Handle fallback section covers the three wrap error shapes and
  how the agent should manually wrap for preview and commit to real
  source on accept.
- Handle accept documents the new `data-impeccable-variant` wrapper
  and the carbonize agent's duty to strip it.

## Prefetch feature (landed but disabled)

A `prefetch` event fires from the browser on first CONFIGURING per
route so the agent can pre-Read the source file before Go. Real latency
win in the linger-before-Go case but costs a harness round trip when
Go fires quickly. Disabled via a `PREFETCH_ENABLED = false` flag in
`live-browser.js`; server validator and skill dispatch stay so re-
enabling (with a browser-side debounce) is a one-line change.

## Harness guidance

Earlier skill rewrite compressed two load-bearing instructions:
- Restored prescriptive wording for "open the tab via Chrome MCP
  before the first poll" and the Claude Code background-poll policy.
- Flag-mapping for `live-wrap` rewritten as explicit bullets so models
  don't collapse `--element-id`/`--classes`/`--tag` into a single
  `--query` argument.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-21 22:30:27 -07:00
co-authored by Claude Opus 4.7
parent e2279ddab1
commit 37b8e8ba33
96 changed files with 4260 additions and 792 deletions
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `$impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `$impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss — the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters — catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for — so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks — see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss — the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters — catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for — so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks — see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss — the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters — catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for — so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks — see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
+46 -30
View File
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
+42 -7
View File
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
+47 -1
View File
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
+42 -6
View File
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
+46 -30
View File
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
+42 -7
View File
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
+46 -30
View File
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
+42 -7
View File
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,
+105 -21
View File
@@ -28,11 +28,11 @@ Chat is overhead. No recap, no tutorial output, no pasting PRODUCT / DESIGN bodi
node {{scripts_path}}/live.mjs
```
Output JSON: `{ ok, serverPort, serverToken, pageFile, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
Output JSON: `{ ok, serverPort, serverToken, pageFiles, hasProduct, product, productPath, hasDesign, design, designPath, migrated }`. `pageFiles` is the list of HTML entries the live script was injected into. Keep PRODUCT.md and DESIGN.md in mind for variant generation — **DESIGN.md wins on visual decisions; PRODUCT.md wins on strategic/voice decisions.** If `migrated: true`, the loader auto-renamed legacy `.impeccable.md` to `PRODUCT.md`; mention this once and suggest `/impeccable document` for the matching DESIGN.md.
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves the HTML entry (`pageFile` / Vite / Next / Bun / tunnel / LAN hostname).
`serverPort` and `serverToken` belong to the small **Impeccable live helper** HTTP server (serves `/live.js`, SSE, and `/poll`). That port is **not** your dev server and is usually not the URL you open to view the app. The browser page is whatever origin serves one of the `pageFiles` entries (Vite / Next / Bun / tunnel / LAN hostname).
If output is `{ ok: false, error: "config_missing", configPath }`, this project hasn't used live mode. See **First-time setup** at the bottom.
If output is `{ ok: false, error: "config_missing" | "config_invalid", path }`, this project hasn't been configured for live mode (or its config is stale). See **First-time setup** at the bottom.
## Poll loop
@@ -44,6 +44,7 @@ LOOP:
"generate" → Handle Generate; reply done; LOOP
"accept" → Handle Accept; LOOP
"discard" → Handle Discard; LOOP
"prefetch" → Handle Prefetch; LOOP
"timeout" → LOOP
"exit" → break → Cleanup
```
@@ -73,9 +74,23 @@ Reading annotations precisely:
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
```
Pass `event.element.id`, `event.element.classes` joined with commas, and `event.element.tagName`. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search.
Flag mapping — keep them separate, don't collapse into `--query`:
Output: `{ file, insertLine, commentSyntax }`. If `wrap` fails, fall back to manual grep + edit.
- `--element-id``event.element.id`
- `--classes``event.element.classes` joined with commas
- `--tag``event.element.tagName`
The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups.
Output on success: `{ file, insertLine, commentSyntax }`.
**Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes:
- `{ error: "file_is_generated", file, hint }` — user-supplied `--file` points at a generated file.
- `{ error: "element_not_in_source", generatedMatch, hint }` — element exists only in a generated file (the next build would wipe any edits).
- `{ error: "element_not_found", hint }` — element isn't in any project file; likely runtime-injected (JS component, data-driven render).
All three carry `fallback: "agent-driven"`. Follow **Handle fallback** below.
### 3. Load the action's reference
@@ -173,24 +188,78 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH
Then run `live-poll.mjs` again immediately.
## Handle fallback
When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here.
The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself.
### Step 1: Identify where the element actually lives
Use the error payload:
- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"` — the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element.
- `element_not_found` — the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it.
- `file_is_generated` with `file: "..."` — user pointed at a generated file explicitly. Same resolution as `element_not_in_source`.
Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template.
### Step 2: Show three variants in the DOM for preview
The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something:
1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces — `<!-- impeccable-variants-start ID --><div data-impeccable-variants="ID" data-impeccable-variant-count="3" style="display: contents">…</div><!-- end -->`.
2. Insert your three variant divs inside it, same shape as the deterministic path.
3. Signal done with `--reply EVENT_ID done --file <served file>`. The browser's no-HMR fallback will fetch and inject.
This served-file edit is **temporary** — next regen wipes it, and that's fine. The real work happens on accept.
### Step 3: On accept, write to true source
When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files — see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1:
- Structural change → edit the template / component source.
- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `<style>` scope.
- Data-driven → update the data source or the render logic.
Then remove the temporary wrapper from the served file if it's still there.
### Step 4: On discard, clean up the served file
Remove the wrapper you inserted in Step 2. Nothing else to do.
## Handle `accept`
Event: `{id, variantId, _acceptResult}`. The poll script already ran `live-accept.mjs` to handle the file operation deterministically; the browser DOM is already updated.
- `_acceptResult.handled: true` and `carbonize: false` — nothing to do. Poll again.
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. Spawn a **background agent** to:
- `_acceptResult.handled: true` and `carbonize: true` — the accepted variant has an inline `<style>` block marked with `impeccable-carbonize-start` / `impeccable-carbonize-end` comments. The accepted content itself is wrapped in a `<div data-impeccable-variant="N" style="display: contents">` so the existing `@scope ([data-impeccable-variant="N"])` rules keep rendering correctly until carbonize runs — the user sees the accepted design immediately, no visual gap. 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.
4. Remove the `<div data-impeccable-variant="N">` wrapper and any helper classes/attributes from the accepted HTML.
5. Delete the carbonize markers and inline `<style>` block.
Poll again immediately; don't wait for the background agent.
- `_acceptResult.handled: false` — manual cleanup: read file, find markers, edit.
- `_acceptResult.handled: false, mode: "fallback"` — the session lived in a generated file and the script refused to persist there. You've already written the accepted variant into true source during Handle fallback Step 3; just clean up the temporary wrapper in the served file if any, and poll again.
- `_acceptResult.handled: false` without `mode` — manual cleanup: read file, find markers, edit.
## Handle `discard`
Event: `{id, _acceptResult}`. The poll script already restored the original and removed all variant markers. Nothing to do. Poll again.
## Handle `prefetch`
Event: `{pageUrl}`. The browser fires this the first time the user selects an element on a given route, as a latency shortcut — it signals the user is likely about to Go on a page you haven't read yet.
Resolve `pageUrl` to the underlying file:
- Root `/` → the `pageFile` returned by `live.mjs` (usually `public/index.html` or equivalent).
- Sub-routes (e.g. `/docs`, `/docs/live`) → the generated or source file for that route. Use your knowledge of the project layout (multi-page static sites often resolve `/foo``public/foo/index.html`; SPAs may map all routes to a single entry).
Read the file into context, then poll again. No `--reply` — this is speculative pre-work; Go will come later. If you can't confidently resolve the route to a file, skip and poll again.
Dedupe is the browser's job (one prefetch per unique pathname per session) — trust it. If the same file shows up twice from different routes mapping to the same file, the second Read is cached anyway.
## Exit
The user can stop live mode by:
@@ -212,19 +281,34 @@ Then:
- Remove any leftover variant wrappers (search for `impeccable-variants-start` markers).
- Remove any leftover carbonize blocks (search for `impeccable-carbonize-start` markers).
## First-time setup (config missing)
## First-time setup (config missing or invalid)
If `live.mjs` outputs `{ ok: false, error: "config_missing", configPath }`, create the config at the reported path based on the project's framework:
If `live.mjs` outputs `{ ok: false, error: "config_missing" | "config_invalid", path }`, write `config.json` at the reported path.
| Framework | `file` | `insertBefore` | `commentSyntax` |
|-----------|--------|----------------|-----------------|
| Plain HTML | `index.html` | `</body>` | `html` |
| Vite / React | `index.html` | `</body>` | `html` |
| Next.js (App Router) | `app/layout.tsx` | `</body>` | `jsx` |
| Next.js (Pages) | `pages/_document.tsx` | `</body>` | `jsx` |
| Nuxt | `app.vue` | `</body>` | `html` |
| Svelte / SvelteKit | `src/app.html` | `</body>` | `html` |
| Astro | the root layout `.astro` file | `</body>` | `html` |
| Static site with a non-root HTML file | e.g. `public/index.html` | `</body>` | `html` |
Schema:
Use `insertAfter` instead of `insertBefore` if the anchor should match **after** a specific line. Then re-run `live.mjs`.
```json
{
"files": ["<path>", "<path>", ...],
"insertBefore": "</body>",
"commentSyntax": "html"
}
```
`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow.
| Framework | `files` | `insertBefore` | `commentSyntax` |
|-----------|---------|----------------|-----------------|
| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `</body>` | `html` |
| Next.js (App Router) | `["app/layout.tsx"]` | `</body>` | `jsx` |
| Next.js (Pages) | `["pages/_document.tsx"]` | `</body>` | `jsx` |
| Nuxt | `["app.vue"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` almost always works). Use `insertAfter` if the anchor should match **after** a specific line.
For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow.
Then re-run `live.mjs`.
@@ -0,0 +1,69 @@
/**
* Decide whether a given file is "generated" (regenerated by a build step,
* unsafe to write variants into) or "source" (safe to edit, changes persist).
*
* Why this matters: when the user picks an element on a page whose underlying
* file is regenerated by a build step (e.g. `scripts/build-sub-pages.js`
* rewriting `public/docs/*.html`), writing variants or accepted changes into
* that file is silent data loss the next build wipes them.
*
* Signals, in order of reliability:
* 1. Git check-ignore: gitignored files are assumed generated.
* 2. File-header markers ("GENERATED", "DO NOT EDIT", "AUTO-GENERATED")
* within the first ~300 characters catches non-git projects.
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const HEADER_SCAN_BYTES = 300;
const HEADER_MARKERS = [
/@generated\b/i,
/\bGENERATED\s+FILE\b/,
/\bAUTO-?GENERATED\b/i,
/\bDO\s+NOT\s+EDIT\b/i,
];
/**
* @param {string} filePath - absolute or cwd-relative path
* @param {object} [options]
* @param {string} [options.cwd] - project root (defaults to process.cwd())
*/
export function isGeneratedFile(filePath, options = {}) {
const cwd = options.cwd || process.cwd();
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
if (isGitIgnored(absPath, cwd)) return true;
if (hasGeneratedHeader(absPath)) return true;
return false;
}
function isGitIgnored(absPath, cwd) {
try {
execSync(`git check-ignore --quiet ${JSON.stringify(absPath)}`, {
cwd,
stdio: 'ignore',
});
return true; // exit 0 = ignored
} catch (err) {
// Exit code 1 = not ignored. Exit code 128 = not a git repo or other error.
// In both cases, treat as "not known to be ignored."
return false;
}
}
function hasGeneratedHeader(absPath) {
let fd;
try {
fd = fs.openSync(absPath, 'r');
const buf = Buffer.alloc(HEADER_SCAN_BYTES);
const bytesRead = fs.readSync(fd, buf, 0, HEADER_SCAN_BYTES, 0);
const head = buf.slice(0, bytesRead).toString('utf-8');
return HEADER_MARKERS.some((re) => re.test(head));
} catch {
return false;
} finally {
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
}
}
@@ -15,6 +15,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -59,6 +60,20 @@ Output (JSON):
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
// Bail if the session lives in a generated file. The agent manually wrote
// the wrapper there for preview, and is responsible for writing the
// accepted variant to true source (or cleaning up on discard). See
// "Handle fallback" in live.md.
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
const result = handleDiscard(id, lines, targetFile);
console.log(JSON.stringify({ handled: true, file: relFile, carbonize: false, ...result }));
@@ -131,7 +146,18 @@ function handleAccept(id, variantNum, lines, targetFile) {
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
}
replacement.push(...restored);
// 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.
if (cssContent) {
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
replacement.push(...restored);
replacement.push(indent + '</div>');
} else {
replacement.push(...restored);
}
const newLines = [
...lines.slice(0, block.start),
@@ -168,15 +194,26 @@ function findMarkerBlock(id, lines) {
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines (still indented as stored in the wrapper).
*
* CSS inside a <style> block can reference `data-impeccable-variant="N"` via
* `@scope`, which would falsely match the HTML div we're looking for so skip
* style regions entirely.
*/
function extractOriginal(lines, block) {
let inOriginal = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inOriginal && line.includes('data-impeccable-variant="original"')) {
inOriginal = true;
depth = 1;
@@ -200,15 +237,24 @@ function extractOriginal(lines, block) {
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*
* Skip <style> blocks see extractOriginal for why.
*/
function extractVariant(lines, block, variantNum) {
let inVariant = false;
let inStyle = false;
let depth = 0;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && /<style[\s>]/.test(line)) { inStyle = true; continue; }
if (inStyle) {
if (line.trimStart().startsWith('</style>')) inStyle = false;
continue;
}
if (!inVariant && line.includes('data-impeccable-variant="' + variantNum + '"')) {
inVariant = true;
depth = 1;
@@ -732,13 +732,26 @@
const r = selectedElement.getBoundingClientRect();
const barH = barEl.offsetHeight || 44;
const barW = barEl.offsetWidth || 380;
let top = r.bottom + 8;
const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
const GAP = 8;
// Prefer below the element; fall back to above; if neither fits (element
// taller than viewport), pin to a stable viewport anchor so the bar
// doesn't teleport between top and bottom as the user scrolls.
let top;
const belowTop = r.bottom + GAP;
const aboveTop = r.top - barH - GAP;
if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
top = belowTop;
} else if (aboveTop >= GAP) {
top = aboveTop;
} else {
top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
}
let left = r.left + (r.width - barW) / 2;
// Keep in viewport
if (top + barH + 8 > window.innerHeight) top = r.top - barH - 8;
if (top < 8) top = 8;
if (left < 8) left = 8;
if (left + barW > window.innerWidth - 8) left = window.innerWidth - barW - 8;
if (left < GAP) left = GAP;
if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
}
@@ -1251,6 +1264,7 @@
selectedElement = pickVariantContent(wrapper, 1) || wrapper.parentElement;
state = 'CYCLING';
hideShaderOverlay();
updateBarContent('cycling');
saveSession();
console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
@@ -1514,6 +1528,28 @@
showAnnotOverlay(selectedElement);
showBar('configure');
startScrollTracking();
maybePrefetchPage();
}
// Fire a lightweight prefetch event the first time the user selects an
// element on a given route. The agent uses this to Read the underlying file
// into context before Go is hit, shaving the read off the critical path.
// Dedupe per session by pathname — clicking around on the same page doesn't
// re-fire.
//
// DISABLED: quick-Go workflows pay an extra harness round trip because
// prefetch + generate arrive as two events instead of one. Re-enable with
// a browser-side debounce (~8001000ms, cancelled on Go) if we want to
// resurrect this. Server validator and skill dispatch remain in place so
// flipping this flag is the only change needed.
const PREFETCH_ENABLED = false;
const prefetchedPaths = new Set();
function maybePrefetchPage() {
if (!PREFETCH_ENABLED) return;
const path = location.pathname;
if (prefetchedPaths.has(path)) return;
prefetchedPaths.add(path);
sendEvent({ type: 'prefetch', pageUrl: path });
}
function handleKeyDown(e) {
@@ -46,12 +46,20 @@ Output (JSON):
console.log(JSON.stringify({ ok: false, error: 'config_missing', path: CONFIG_PATH }));
process.exit(0);
}
let cfg;
try {
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message }));
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
try {
validateConfig(cfg);
} catch (err) {
console.log(JSON.stringify({ ok: false, error: 'config_invalid', message: err.message, path: CONFIG_PATH }));
return;
}
console.log(JSON.stringify({ ok: true, config: cfg, path: CONFIG_PATH }));
return;
}
@@ -63,22 +71,17 @@ Output (JSON):
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
validateConfig(config);
const absFile = path.resolve(process.cwd(), config.file);
if (!fs.existsSync(absFile)) {
console.error(JSON.stringify({ ok: false, error: 'file_not_found', file: config.file }));
process.exit(1);
}
const content = fs.readFileSync(absFile, 'utf-8');
if (args.includes('--remove')) {
const updated = removeTag(content, config.commentSyntax);
if (updated === content) {
console.log(JSON.stringify({ ok: true, file: config.file, removed: false, note: 'no tag present' }));
return;
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, removed: true }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const updated = removeTag(content, config.commentSyntax);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, removed: true };
});
console.log(JSON.stringify({ ok: true, results }));
return;
}
@@ -90,15 +93,19 @@ Output (JSON):
process.exit(1);
}
// Already inserted? Replace to refresh the port.
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) {
console.error(JSON.stringify({ ok: false, error: 'insertion_point_not_found', anchor: config.insertBefore }));
process.exit(1);
}
fs.writeFileSync(absFile, updated, 'utf-8');
console.log(JSON.stringify({ ok: true, file: config.file, inserted: true, port }));
const results = config.files.map((relFile) => {
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
const withoutOld = removeTag(content, config.commentSyntax);
const updated = insertTag(withoutOld, config, port);
if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
fs.writeFileSync(absFile, updated, 'utf-8');
return { file: relFile, inserted: true };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
if (!anyInserted) process.exit(1);
}
// ---------------------------------------------------------------------------
@@ -107,7 +114,12 @@ Output (JSON):
function validateConfig(cfg) {
if (!cfg || typeof cfg !== 'object') throw new Error('config.json must be an object');
if (typeof cfg.file !== 'string') throw new Error('config.file (string) required');
if (!Array.isArray(cfg.files) || cfg.files.length === 0) {
throw new Error('config.files (non-empty string array) required');
}
if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) {
throw new Error('config.files must contain only non-empty strings');
}
if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') {
throw new Error('config.insertBefore or config.insertAfter (string) required');
}
@@ -131,12 +143,16 @@ function buildTagBlock(syntax, port) {
function insertTag(content, config, port) {
const block = buildTagBlock(config.commentSyntax, port);
// insertBefore: match the LAST occurrence. Anchors like `</body>` 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.indexOf(config.insertBefore);
const idx = content.lastIndexOf(config.insertBefore);
if (idx === -1) return content;
return content.slice(0, idx) + block + content.slice(idx);
}
// insertAfter
// insertAfter: match the FIRST occurrence — typical anchors like `<head>` or
// `<body>` open near the top of the document.
const idx = content.indexOf(config.insertAfter);
if (idx === -1) return content;
const after = idx + config.insertAfter.length;
@@ -151,6 +151,9 @@ function validateEvent(msg) {
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;
}
+42 -7
View File
@@ -13,6 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './is-generated.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
@@ -62,19 +63,52 @@ The agent should insert variant HTML at insertLine.`);
// Build search queries in priority order (most specific first)
const queries = buildSearchQueries(elementId, classes, tag, query);
// Find the source file
const genOpts = { cwd: process.cwd() };
// Find the source file. Generated files are excluded from auto-search so we
// don't silently write variants into a file the next build will wipe.
let targetFile = filePath;
let matchedQuery = null;
if (!targetFile) {
for (const q of queries) {
targetFile = findFileWithQuery(q, process.cwd());
targetFile = findFileWithQuery(q, process.cwd(), genOpts);
if (targetFile) { matchedQuery = q; break; }
}
if (!targetFile) {
console.error(JSON.stringify({ error: 'Could not find element in project files. Searched for: ' + queries.join(', ') }));
// Nothing in source. Did the element show up in a generated file? That
// tells the agent "fall back to the agent-driven flow" vs "element just
// doesn't exist in this project."
let generatedHit = null;
for (const q of queries) {
generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
if (generatedHit) break;
}
if (generatedHit) {
console.error(JSON.stringify({
error: 'element_not_in_source',
fallback: 'agent-driven',
generatedMatch: path.relative(process.cwd(), generatedHit),
hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
}));
} else {
console.error(JSON.stringify({
error: 'element_not_found',
fallback: 'agent-driven',
hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
}));
}
process.exit(1);
}
} else {
if (isGeneratedFile(targetFile, genOpts)) {
console.error(JSON.stringify({
error: 'file_is_generated',
fallback: 'agent-driven',
file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
matchedQuery = queries[0];
}
@@ -195,20 +229,20 @@ function detectCommentSyntax(filePath) {
* Search project files for the query string (class name, ID, etc.)
* Returns the first matching file path, or null.
*/
function findFileWithQuery(query, cwd) {
function findFileWithQuery(query, cwd, genOpts = {}) {
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, query, seen, 0);
const result = searchDir(absDir, query, seen, 0, genOpts);
if (result) return result;
}
return null;
}
function searchDir(dir, query, seen, depth) {
function searchDir(dir, query, seen, depth, genOpts) {
if (depth > 5) return null; // don't go too deep
const realDir = fs.realpathSync(dir);
if (seen.has(realDir)) return null;
@@ -225,6 +259,7 @@ function searchDir(dir, query, seen, depth) {
if (!EXTENSIONS.includes(ext)) continue;
const filePath = path.join(dir, entry.name);
if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue;
try {
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(query)) return filePath;
@@ -235,7 +270,7 @@ function searchDir(dir, query, seen, depth) {
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'dist' || entry.name === 'build') continue;
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1);
const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts);
if (result) return result;
}
+1 -1
View File
@@ -87,7 +87,7 @@ The agent should then:
ok: true,
serverPort: serverInfo.port,
serverToken: serverInfo.token,
pageFile: checkResult.config.file,
pageFiles: checkResult.config.files,
hasProduct: ctx.hasProduct,
product: ctx.product,
productPath: ctx.productPath,