mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.7
parent
e2279ddab1
commit
37b8e8ba33
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (~800–1000ms, 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user