diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 35c7d4c26..ba39f83f2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,9 +6,16 @@ on:
pull_request:
branches: [main]
workflow_dispatch:
+ # Nightly full live-e2e matrix. The smoke groups already gate every PR; the
+ # full sweep is too slow for that, so it runs once a day against main.
+ schedule:
+ - cron: '0 7 * * *'
concurrency:
- group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ # Scheduled runs get their own group: the 07:00 UTC nightly and a push to
+ # main share github.ref, and cancel-in-progress would let them kill each
+ # other mid-run.
+ group: ${{ github.workflow }}-${{ github.event_name == 'schedule' && 'nightly' || github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -157,14 +164,16 @@ jobs:
name: live-e2e smoke (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
- if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch'
+ if: needs.changes.outputs.live_e2e == 'true' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
timeout-minutes: 15
strategy:
fail-fast: true
matrix:
include:
- group: platform
- fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
+ fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router,vite8-sveltekit
+ - group: svelte
+ fixtures: vite8-sveltekit-stateful
- group: react
fixtures: vite8-react-css-modules,vite8-react-insert,vite8-react-plain
steps:
@@ -225,14 +234,16 @@ jobs:
name: live-e2e full (${{ matrix.group }})
runs-on: ubuntu-latest
needs: changes
- if: needs.changes.outputs.live_e2e == 'true' && github.event_name == 'workflow_dispatch'
+ if: needs.changes.outputs.live_e2e == 'true' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
timeout-minutes: 25
strategy:
fail-fast: true
matrix:
include:
- group: platform
- fixtures: astro-vite7,nextjs-app-router,vite8-sveltekit
+ fixtures: astro-vite7,monorepo-nested-vite,nextjs-app-router
+ - group: svelte
+ fixtures: vite8-sveltekit,vite8-sveltekit-stateful
- group: react-a
fixtures: vite8-https,vite8-react-base-path,vite8-react-csp-meta,vite8-react-css-modules,vite8-react-emotion
- group: react-b
diff --git a/.impeccable/config.json b/.impeccable/config.json
index 84e557937..95ab71955 100644
--- a/.impeccable/config.json
+++ b/.impeccable/config.json
@@ -3,6 +3,7 @@
"ignoreRules": [],
"ignoreFiles": [
"tests/fixtures/**",
+ "tests/framework-fixtures/**",
"tests/detect-antipatterns.test.js"
],
"ignoreValues": [
diff --git a/CLAUDE.md b/CLAUDE.md
index be13c85c7..ce0fa44b4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -159,9 +159,15 @@ IMPECCABLE_E2E_DEBUG=1 bun run test:live-e2e # dump page DOM + de
**Kept out of the default `bun run test`** because (a) it does real `npm install` per fixture, (b) it boots framework dev servers, (c) wall time is ~2 minutes, and (d) it requires Playwright's browser cache. Run it locally before shipping changes to anything in `skill/scripts/live-*.{mjs,js}` or `skill/scripts/live/**`.
+Three live-mode invariants worth knowing before editing (established by the 2026-07 rewrite, full rationale in `docs/LIVE-REWRITE-PLAN.md`):
+
+- **Roots.** `skill/scripts/live/roots.mjs` resolves appRoot/repoRoot/contextRoot once at boot and persists `.impeccable/live/roots.json`; every live CLI calls `enterLiveRoot()` in its main guard and chdirs onto the manifest's appRoot. Never derive a live path from ambient cwd in a new script; go through the manifest.
+- **Svelte preview modules must live under `node_modules/.impeccable-live`.** SvelteKit restricts vite `server.fs.allow` to src/lib, src/routes, .svelte-kit, and node_modules; a preview tree under `.impeccable/` 403s. Staleness is handled by per-publish revision dirs (`r/`, bumped by the server on every done-reply), not by file watching.
+- **`svelte` is a devDependency for tests only.** The AST scaffolder (`live/svelte-ast.mjs`) and accept pipeline (`live/accept-css.mjs`) resolve the compiler from the USER app's node_modules at runtime; unit tests and the static fixture sweep symlink this repo's copy into staged fixtures. Skill scripts still ship dependency-free.
+
The agent is pluggable via a one-method interface in `tests/live-e2e/agent.mjs`: `generateVariants(event, context) → { scopedCss, variants[] }`. The default fake agent emits canned variants that exercise all three param kinds (`range`, `steps`, `toggle`). The orchestrator (wrap, write, accept, carbonize) is agent-agnostic.
-**LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`, which calls Claude (default Haiku 4.5) via `@anthropic-ai/sdk`. Requires `ANTHROPIC_API_KEY` in env; the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL=claude-sonnet-4-6` if Haiku produces unreliable JSON. Caching is on — live.md is the cacheable prefix, and after the first call subsequent fixtures pay only the cache-read rate. Pass rate on a typical sweep is 18/19; the modal fixture's intrinsic state-loss flake is amplified by LLM latency and may need a re-run. **This path hits the API and costs money** — keep it out of CI unless you really want it there.
+**LLM agent (opt-in)**: set `IMPECCABLE_E2E_AGENT=llm` to swap the fake agent for `tests/live-e2e/agents/llm-agent.mjs`. Default provider/model: OpenAI `gpt-5.6-terra` at medium reasoning effort (a frontier tier, matching what drives real live sessions); Anthropic and DeepSeek remain selectable via `IMPECCABLE_E2E_LLM_PROVIDER`. Requires the selected provider's key in env (`OPENAI_API_KEY` by default); the test runner skips with a clear message when it's unset. Override the model with `IMPECCABLE_E2E_LLM_MODEL` and the effort with `IMPECCABLE_E2E_LLM_EFFORT`. Caching is on — live.md is the cacheable prefix, and after the first call subsequent fixtures pay only the cache-read rate. Pass rate on a typical sweep is 18/19; the modal fixture's intrinsic state-loss flake is amplified by LLM latency and may need a re-run. **This path hits the API and costs money** — keep it out of CI unless you really want it there.
Adding a new fixture is a matter of cloning a directory under `tests/framework-fixtures/`, swapping the source files, and writing a `fixture.json`. See `tests/framework-fixtures/README.md` for the full schema.
diff --git a/bun.lock b/bun.lock
index fe064f01d..e55ed8913 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,7 @@
"ai": "^7.0.14",
"archiver": "^8.0.0",
"playwright": "^1.59.1",
+ "svelte": "^5",
"zod": "^4.3.6",
},
"optionalDependencies": {
@@ -74,6 +75,16 @@
"@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"@puppeteer/browsers": ["@puppeteer/browsers@3.0.6", "", { "dependencies": { "modern-tar": "^0.7.6", "yargs": "^18.0.0" }, "peerDependencies": { "proxy-agent": ">=8.0.1", "yauzl": "^2.10.0 || ^3.4.0" }, "optionalPeers": ["proxy-agent", "yauzl"], "bin": { "browsers": "lib/main-cli.js" } }, "sha512-B/gKoqlFkzhvzsI6jo9K1cZz9o5ypviVv/xu8CwA4grZzyVwN+XfkT+tu8T1zrauuEXv6VhS2oGX+6NL95WcKA=="],
@@ -82,6 +93,12 @@
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+ "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
+
+ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@vercel/oidc": ["@vercel/oidc@3.2.0", "", {}, "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug=="],
"@workflow/serde": ["@workflow/serde@4.1.0", "", {}, "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ=="],
@@ -90,6 +107,8 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+ "acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="],
+
"ai": ["ai@7.0.37", "", { "dependencies": { "@ai-sdk/gateway": "4.0.28", "@ai-sdk/provider": "4.0.3", "@ai-sdk/provider-utils": "5.0.12" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-stF+SEQJgKY3Qfe3FwNzqUrehHviOp2l7LemoI8YMOa0Zk5PxKsRNgUk3cHXz0RAue9RRyCAis2fz6LSCP6EKw=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
@@ -102,8 +121,12 @@
"archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
+ "aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
+
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
+ "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
+
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
@@ -142,6 +165,8 @@
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
+ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
+
"compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@@ -172,6 +197,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+ "devalue": ["devalue@5.8.2", "", {}, "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA=="],
+
"devtools-protocol": ["devtools-protocol@0.0.1653615", "", {}, "sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA=="],
"dom-serializer": ["dom-serializer@3.1.1", "", { "dependencies": { "domelementtype": "^3.0.0", "domhandler": "^6.0.0", "entities": "^8.0.0" } }, "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw=="],
@@ -202,6 +229,10 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+ "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
+
+ "esrap": ["esrap@2.3.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" }, "peerDependencies": { "@typescript-eslint/types": "^8.2.0" }, "optionalPeers": ["@typescript-eslint/types"] }, "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng=="],
+
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
@@ -270,6 +301,8 @@
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+ "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
+
"is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
@@ -290,6 +323,10 @@
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
+ "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
+
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"marked": ["marked@18.0.7", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-iDVQ5ldaiKXn6b2JroX5kgRfmwgqolW7NpaEzTl1k/2Zh1njIEN9yniyLV/mOvWwtsE8OGgkjsCYvijuPk1dtA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
@@ -398,6 +435,8 @@
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
+ "svelte": ["svelte@5.56.8", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg=="],
+
"tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="],
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
@@ -434,6 +473,8 @@
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
+ "zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
+
"zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="],
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
diff --git a/docs/LIVE-REWRITE-PLAN.md b/docs/LIVE-REWRITE-PLAN.md
new file mode 100644
index 000000000..431ec046f
--- /dev/null
+++ b/docs/LIVE-REWRITE-PLAN.md
@@ -0,0 +1,165 @@
+# Live v2: architecture plan
+
+> **Status (2026-07-27): implemented.** P0 through P3 landed in one pass: roots manifest (`skill/scripts/live/roots.mjs`, every live CLI re-anchors via `enterLiveRoot`), mount-ack protocol with per-variant render truth and persistent error card, server-first rehydration, AST scaffolder (`live/svelte-ast.mjs`) with source-preview fallback, unified mechanical accept (`live/accept-css.mjs`, compiler-pruned; `live-complete` refuses dirty source), revisioned preview dirs bumped per publish, attach probe with named root-mismatch diagnosis, monorepo runtime fixture, and e2e failing on preview-tree 404s. One deliberate deviation from section 3.3: the preview tree stays under `node_modules/.impeccable-live` because SvelteKit restricts vite `server.fs.allow` to src/lib, routes, .svelte-kit, and node_modules (verified: `.impeccable/` under the app root 403s); staleness is defeated by per-publish revision directories instead of watcher reliance. P4 (registry, consolidation, control-flow fixtures, nightly matrix) in flight.
+
+Driven by the 2026-07-25 Codex session in `~/code/agent-reviews` (session `019f9bf4-24a3-7661-afc5-bba7eb1e327f`), where a SvelteKit monorepo live session hit a chain of failures: wrong project root, silent 404 on variant modules, flattened `{#each}` blocks, stale module republish, unrecoverable browser state loss, and an accept that appended CSS instead of merging it. Every finding below was verified against current source; this is not a transcription of the Codex report.
+
+## 1. Verified findings (Codex claim → root cause in code)
+
+| # | Codex finding | Verified root cause |
+|---|---|---|
+| 1 | Wrong monorepo root | `findMonorepoRoot()` (`skill/scripts/context.mjs:262-303`) only recognizes "official" monorepos (workspaces field, turbo/pnpm/nx/lerna markers). A plain repo with a nested `website/` app falls into the non-monorepo branch and roots at cwd. `nearestTargetContextRoot` deliberately ignores `package.json` as a marker. Nothing ever correlates the dev server's root with `projectRoot`. |
+| 2 | Published mistaken for rendered | `arrivedVariants` is backfilled from `expectedVariants` on the agent's `--reply done` (`live/session-store.mjs:211-241`). On the component-preview path the browser never sends a mount checkpoint; success calls only `saveSession()` (`live-browser.js:5592`). There is no `mounted` / `mount_failed` anywhere in the protocol. |
+| 3 | Silent mount failures | Import catch is `console.error` + `return false` (`live-browser.js:5413-5419`). First-mount failure calls `abortSvelteComponentInjection` (`:5621-5651`), which clears localStorage, resets to PICKING, shows a 5s toast, and tells the server nothing. Variant-switch failure has no user feedback at all (`:4941-4950`). |
+| 4 | Svelte `{#each}` flattened | Prop extraction is pure regex (`live/svelte-component.mjs:18,44-88`). `{#each}` / `{/if}` block tokens become scalar string props (`prop0`, `prop5`); a 5-item loop scaffolds as one `
`. A second, independent slot-shift bug: `buildSvelteExpressionTextMap` (`live-browser.js:5830-5871`) zips source tokens against live text nodes by index, and block tokens consume slots. |
+| 5 | Brittle temp module paths | Modules written to `/node_modules/.impeccable-live/` but imported from `location.origin` root-relative (`live-browser.js:5163-5165`). Two roots that must agree by convention. Manifest/CSS come from the helper's `/source`; the executable module from the dev server. Two transports for one artifact. |
+| 6 | Stale republish | Cache-bust is only a client-side `?t=Date.now()` on the leaf module (`live-browser.js:5377`). Vite's watcher ignores `node_modules`, so a rewrite of `vN.svelte` never invalidates the dev server's transform cache. Scaffold is write-once (`svelte-component.mjs:173`). No revision in the path; the runtime shim is memoized forever. |
+| 7 | Browser-local state is a single point of failure | Every restore path gates on localStorage first (`live-browser.js:8196-8246`). Server `activeSessions` only enrich an already-known local id. Mount failure deliberately wipes local state, manufacturing the orphaned-durable-session case. |
+| 8 | Parent context not discovered | Context root and project root are one variable. In the non-monorepo branch `repoRoot = absCwd` (`context.mjs:213`), so `website/` never looks one directory up for PRODUCT.md / DESIGN.md. The Codex session worked around it with symlinks that are still on disk. |
+| 9 | DESIGN panel disagrees with helper | Two causes: the panel is content-driven (needs frontmatter/sidecar, `live-browser.js:11037-11068`) while `hasDesign` is presence-driven; and the server snapshot of context is frozen at module load (`live-server.mjs:61-65`) while `live.mjs` re-resolves per boot and reuses a running server. |
+| 10 | Stop leaves `__runtime.js` | Sweeper skips non-directories and `__*` names (`svelte-component.mjs:732-742`). Confirmed still on disk in agent-reviews. Also unswept: accept receipts, session journals, deferred accepts in `os.tmpdir()`. |
+| 11 | CSS appended, not merged | `appendCssToSvelteStyle` (`svelte-component.mjs:278-296`) splices the variant CSS before the last ``. No reconciliation exists. Worse: `mergeOriginalTopLevelAttrs` (`:646-681`) copies the original root's classes onto the new markup, guaranteeing stale rules keep matching. |
+| 12 | Incomplete param baking | `sanitizeAcceptedSvelteCss` early-returns unless CSS contains `data-impeccable-variant` (`svelte-component.mjs:311-312`), which authoring rules forbid on this path, so the entire steps/toggle pruning pipeline is unreachable dead code. Only `range` vars get substituted; `toggle` bakes the raw JS boolean (`true`) into CSS. |
+| 13 | Formatting destroyed | `line.trimStart()` + one flat indent for every markup line (`svelte-component.mjs:544-547`); every CSS line reindented to 2 spaces (`:280`). No formatter anywhere. The Branch-H (HTML/JSX) path preserves relative indent; the Svelte path regressed against its own sibling. |
+| 14 | Preview cascade ≠ accepted cascade | Svelte scoping is compile-time per file; a detached preview component inherits none of the route's scoped CSS, so variants reimplement everything. The runtime then injects the variant CSS a second time, re-prefixed and un-hashed (`live-browser.js:5220-5280`), with different specificity than the compiled copy. |
+| 18 | Steer affordance | No Send button; Enter-only (`live-browser.js:9558-9712`). Good loading state, no queue-position feedback, 120s timeout blames the wrong component. The element-level Go bar has a visible submit button; the steer bar doesn't. |
+
+Findings 15-17 (hook anti-pattern gaps, copy quality, visual-collision detection) are design-hook scope, not live scope; tracked separately.
+
+Structural facts that explain why these all shipped:
+
+- The 826-line `svelte-component.mjs`, which owns scaffolding, CSS append, and param baking, has **zero direct unit tests**. The only runtime Svelte fixture (`vite8-sveltekit`) has no props, no `{#if}`, no `{#each}`, no `\n' +
- open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n'
- );
-}
-
-function detectLineEnding(content) {
- if (content.includes('\r\n')) return '\r\n';
- if (content.includes('\r')) return '\r';
- return '\n';
-}
-
-function normalizeLineEndings(content, lineEnding) {
- return lineEnding === '\n' ? content : content.replace(/\n/g, lineEnding);
-}
-
-function readLineEndingAt(content, index) {
- if (content[index] === '\r' && content[index + 1] === '\n') return '\r\n';
- if (content[index] === '\n') return '\n';
- if (content[index] === '\r') return '\r';
- return '';
-}
-
-function insertTag(content, config, port, filePath, token) {
- const lineEnding = detectLineEnding(content);
- const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding);
- // insertBefore: match the LAST occurrence. Anchors like `
` open near the top of the document.
- const idx = content.indexOf(config.insertAfter);
- if (idx === -1) return content;
- const after = idx + config.insertAfter.length;
- // Preserve an existing trailing newline if the anchor already has one.
- // Slice the remainder from the original anchor offset, not prefix.length:
- // in the no-newline case prefix is one char longer than the anchor (the
- // appended '\n'), so slicing by prefix.length would drop the first real
- // character after the anchor (#227).
- const existingNewline = readLineEndingAt(content, after);
- const prefix = content.slice(0, after) + (existingNewline || lineEnding);
- const rest = content.slice(after + existingNewline.length);
- return prefix + block + rest;
-}
-
-/**
- * Remove the live script block. Matches either HTML or JSX comment markers
- * regardless of config (so stale tags from a wrong config can still be cleaned).
- *
- * Indent-preserving: captures any whitespace immediately preceding the opener
- * marker and re-emits it in place of the removed block. `insertTag` inserted
- * the block *after* the original line's indent and *before* the anchor (e.g.
- * `
` naturally
- // belong at the end, and the same literal can appear earlier in code blocks
- // within rendered documentation pages.
- if (config.insertBefore) {
- const idx = content.lastIndexOf(config.insertBefore);
- if (idx === -1) return content;
- return content.slice(0, idx) + block + content.slice(idx);
- }
- // insertAfter: match the FIRST occurrence — typical anchors like `
` or
- // `
`), which moved the indent onto the opener line and left the anchor
- * unindented. Replacing the whole block (plus its trailing newline) with just
- * the captured indent hands the indent back to the anchor that follows.
- */
-function removeTag(content, _syntax) {
- const patterns = [
- /([ \t]*)[\s\S]*?([ \t]*(?:\r\n|\n|\r|$)?)/,
- /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\r\n|\n|\r|$)?)/,
- ];
- for (const pat of patterns) {
- let changed = false;
- let next = content;
- do {
- content = next;
- next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
- if (/[\r\n]/.test(trailing)) return leadingIndent;
- return leadingIndent || trailing || '';
- });
- if (next !== content) changed = true;
- } while (next !== content);
- if (changed) return next;
- }
- return content;
-}
-
-// ---------------------------------------------------------------------------
-// Content-Security-Policy meta-tag patcher
-//
-// When the user's HTML carries ``,
-// the cross-origin load of /live.js (and the SSE/POST connection back to
-// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
-//
-// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
-// and stash the original `content` value in a `data-impeccable-csp-original`
-// attribute (base64) so revert is exact.
-//
-// On remove: detect the marker attribute, decode it, restore the original
-// content value verbatim, drop the marker.
-//
-// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
-// shared helpers) is NOT patched here — those need framework-specific config
-// edits and are handled via the existing detect-csp.mjs reference output.
-// Only the in-source meta-tag form gets the auto-patch.
-// ---------------------------------------------------------------------------
-
-const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
-
-function findCspMetaTags(content) {
- const out = [];
- const tagRe = /]*?)\/?>/gis;
- let m;
- while ((m = tagRe.exec(content)) !== null) {
- const attrs = m[1];
- if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
- out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
- }
- return out;
-}
-
-function getAttr(attrs, name) {
- const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
- const m = attrs.match(re);
- return m ? { quote: m[1], value: m[2], full: m[0] } : null;
-}
-
-function appendOriginToDirective(csp, directive, origin) {
- const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
- const m = csp.match(re);
- if (m) {
- const tokens = m[4].trim().split(/\s+/);
- if (tokens.includes(origin)) return csp;
- return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
- }
- // Directive missing — add it. Use 'self' + origin so we don't inadvertently
- // narrow the policy compared to the default-src fallback (most users with
- // an explicit CSP have 'self' there).
- return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
-}
-
-export function patchCspMeta(content, port) {
- const tags = findCspMetaTags(content);
- if (tags.length === 0) return content;
- const origin = `http://localhost:${port}`;
-
- // Walk last-to-first so prior splices don't invalidate later indices.
- let result = content;
- for (let i = tags.length - 1; i >= 0; i--) {
- const tag = tags[i];
- const attrs = tag.attrs;
- if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
- const contentAttr = getAttr(attrs, 'content');
- if (!contentAttr) continue;
-
- const original = contentAttr.value;
- let patched = original;
- patched = appendOriginToDirective(patched, 'script-src', origin);
- patched = appendOriginToDirective(patched, 'connect-src', origin);
- // The shader overlay during 'generating' creates a screenshot via
- // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
- // those. Add `blob:` so the overlay doesn't throw a CSP violation.
- patched = appendOriginToDirective(patched, 'img-src', 'blob:');
- if (patched === original) continue;
-
- const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
- const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
- // The tagRe captures any whitespace between the last attribute and the
- // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
- // a replace would land it BEFORE that trailing space, leaving a double
- // space inside attrs and clobbering the space before `/>`. Split off
- // the trailing whitespace, splice the marker into the attribute body,
- // and re-append the original trailing whitespace so a self-closing
- // `` round-trips byte-for-byte.
- const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
- const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
- const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
- const newTag = tag.full.replace(attrs, newAttrs);
-
- result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
- }
- return result;
-}
-
-export function revertCspMeta(content) {
- const tags = findCspMetaTags(content);
- if (tags.length === 0) return content;
-
- let result = content;
- for (let i = tags.length - 1; i >= 0; i--) {
- const tag = tags[i];
- const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
- if (!origAttr) continue;
- const contentAttr = getAttr(tag.attrs, 'content');
- if (!contentAttr) continue;
-
- let originalValue;
- try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
- catch { continue; }
-
- const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
- let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
- // Drop the marker attribute and any single space immediately preceding it.
- newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
- const newTag = tag.full.replace(tag.attrs, newAttrs);
-
- result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
- }
- return result;
-}
-
// ---------------------------------------------------------------------------
// Auto-execute
// ---------------------------------------------------------------------------
const _running = process.argv[1];
if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
+ enterLiveRoot();
injectCli();
}
-export { insertTag, removeTag, validateConfig, buildTagBlock };
-// patchCspMeta + revertCspMeta are exported above where they're defined.
+// Re-exported so long-standing importers (live.mjs, the adapter modules, the
+// test suites) keep their entry points while the implementations live in
+// live/frameworks/.
+export {
+ buildLiveScriptSrc,
+ buildTagBlock,
+ insertTag,
+ patchCspMeta,
+ removeTag,
+ revertCspMeta,
+ validateConfig,
+};
+export {
+ applyNuxtLiveAdapter,
+ buildNuxtPlugin,
+ detectNuxtProject,
+ removeNuxtLiveAdapter,
+} from './live/frameworks/nuxt.mjs';
diff --git a/skill/scripts/live-insert.mjs b/skill/scripts/live-insert.mjs
index b4c17cca1..8d5829ea4 100644
--- a/skill/scripts/live-insert.mjs
+++ b/skill/scripts/live-insert.mjs
@@ -26,6 +26,7 @@ import {
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
} from './live/svelte-component.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
@@ -286,5 +287,6 @@ Output (JSON):
const _running = process.argv[1];
if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
+ enterLiveRoot();
insertCli();
}
diff --git a/skill/scripts/live-poll.mjs b/skill/scripts/live-poll.mjs
index 19a8b0f9b..3b2f08c9f 100644
--- a/skill/scripts/live-poll.mjs
+++ b/skill/scripts/live-poll.mjs
@@ -14,6 +14,8 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
+import { instructionsForEvent } from './live/instructions.mjs';
// Absolute path to a sibling script in this skill's scripts dir, so runtime
// error hints print a directly-runnable command instead of a placeholder.
@@ -27,7 +29,7 @@ const scriptCmd = (name) => `node "${path.join(SELF_DIR, name)}"`;
export const PER_REQUEST_TIMEOUT_MS = 270_000;
export const DEFAULT_EVENT_LEASE_MS = 600_000;
-const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup']);
+const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply', 'carbonize_cleanup', 'variant_mount_failed']);
function readServerInfo() {
const record = readLiveServerInfo(process.cwd());
@@ -117,8 +119,11 @@ export async function postReply(base, token, reply) {
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
- const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean);
- throw new Error(parts.join(': '));
+ const failureLines = Array.isArray(body.failures)
+ ? body.failures.map((f) => ` ${f.file}${f.line != null ? `:${f.line}` : ''} ${f.message}`).join('\n')
+ : null;
+ const parts = [body.error || res.statusText, body.reason, body.hint, failureLines, body._instructions].filter(Boolean);
+ throw new Error(parts.join('\n'));
}
}
@@ -261,6 +266,13 @@ export function writeCarbonizeBanner(event) {
}
export function printPollEvent(event) {
+ // Situational plumbing rides with the event itself: `_instructions` is the
+ // authoritative next step, with real ids and paths substituted, so the
+ // reference doc can stay lean and can never drift from script behavior.
+ if (event && typeof event === 'object' && !event._instructions) {
+ const instructions = instructionsForEvent(event, { scriptsPath: SELF_DIR });
+ if (instructions) event._instructions = instructions;
+ }
console.log(JSON.stringify(event));
}
@@ -412,5 +424,6 @@ export function normalizePollTypes(value) {
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
+ enterLiveRoot();
pollCli();
}
diff --git a/skill/scripts/live-resume.mjs b/skill/scripts/live-resume.mjs
index 74284d48a..b9459a8d4 100644
--- a/skill/scripts/live-resume.mjs
+++ b/skill/scripts/live-resume.mjs
@@ -4,6 +4,7 @@
*/
import { createLiveSessionStore } from './live/session-store.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
@@ -49,6 +50,28 @@ function collectManualApplyFiles(batch) {
return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
}
+/**
+ * The browser's render truth, folded into a small block the agent reads before
+ * it decides what to do. `arrivedVariants` only says the agent published;
+ * `renderState` says whether any of it reached a screen.
+ */
+export function renderSummary(snapshot = {}) {
+ return {
+ renderState: snapshot.renderState ?? null,
+ mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
+ mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
+ };
+}
+
+export function mountFailureAction(snapshot = {}) {
+ const failures = Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [];
+ const latest = failures[failures.length - 1];
+ if (!latest) return null;
+ const where = latest.url ? ` from ${latest.url}` : '';
+ const why = latest.error ? ` (${latest.error})` : '';
+ return `The browser failed to mount variant ${latest.variant}${where}${why}; nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply ${snapshot?.pendingEvent?.id || snapshot?.id || 'SESSION_ID'} done --file for the queued variant_mount_failed event (or republish) so the browser retries.`;
+}
+
function parseArgs(argv) {
const out = { id: null };
for (let i = 0; i < argv.length; i++) {
@@ -75,20 +98,26 @@ export async function resumeCli() {
}
const pending = snapshot.pendingEvent || null;
- const nextAction = pending
- ? pending.type === 'manual_edit_apply'
- ? manualApplyResumeHint(pending)
- : `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
- : snapshot.phase === 'carbonize_required'
- ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
- : snapshot.phase === 'accept_requested'
- ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
- : `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
+ const render = renderSummary(snapshot);
+ // A failed render outranks the generic pending-event hint: the agent needs to
+ // know the user is staring at an error card, not at variants. A leased manual
+ // Apply still outranks both, because abandoning that lease loses user edits.
+ const mountAction = render.renderState === 'failed' ? mountFailureAction(snapshot) : null;
+ const nextAction = pending?.type === 'manual_edit_apply'
+ ? manualApplyResumeHint(pending)
+ : mountAction || (pending
+ ? `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
+ : snapshot.phase === 'carbonize_required'
+ ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
+ : snapshot.phase === 'accept_requested'
+ ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
+ : `Inspect ${snapshot.id}; no pending agent event is currently queued.`);
- console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
+ console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, render, nextAction }, null, 2));
}
const _running = process.argv[1];
if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
+ enterLiveRoot();
resumeCli();
}
diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs
index 113ad01b3..bfad7a245 100644
--- a/skill/scripts/live-server.mjs
+++ b/skill/scripts/live-server.mjs
@@ -33,7 +33,10 @@ import { runGenerationPreflight } from './live/generation-preflight.mjs';
import { validateEvent } from './live/event-validation.mjs';
import { selectAvailablePendingEvent } from './live/poll-lanes.mjs';
import { createManualEditRoutes } from './live/manual-edit-routes.mjs';
-import { LIVE_COMMANDS } from './live/vocabulary.mjs';
+import {
+ LIVE_COMMANDS,
+ VARIANT_PROGRESS_CHECKPOINT_REASONS as VARIANT_PROGRESS_CHECKPOINT_REASON_LIST,
+} from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -51,24 +54,53 @@ import {
} from './live/manual-apply.mjs';
import {
applyDeferredSvelteComponentAccepts,
+ bumpSvelteComponentPreviewRevision,
+ compileCheckVariants,
removeAllSvelteComponentSessions,
+ sweepInactiveSvelteComponentSessions,
} from './live/svelte-component.mjs';
+import { enterLiveRoot } from './live/roots.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
-// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
-// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
-// DESIGN.json fallback for existing projects.
-const PROJECT_CONTEXT = loadContext(process.cwd());
-const CONTEXT_DIR = PROJECT_CONTEXT.contextDir;
-const DESIGN_MD_PATH = PROJECT_CONTEXT.designPath
- ? path.resolve(process.cwd(), PROJECT_CONTEXT.designPath)
- : null;
+// Anchor the whole process on the live roots manifest before anything derives
+// a path from cwd. A server started from the wrong directory re-roots itself
+// onto the appRoot the boot decided on instead of minting a second project.
+const LIVE_ROOTS = enterLiveRoot(process.cwd());
+
+// PRODUCT.md / DESIGN.md context, resolved lazily and per request so a server
+// that outlives an `impeccable document` run (or a context file created after
+// boot) reports current truth instead of a boot-time snapshot. The roots
+// manifest wins when the ambient resolution misses (nested app inheriting
+// repo-level context files).
+function resolveProjectContext() {
+ const ctx = loadContext(process.cwd());
+ const designPath = ctx.designPath
+ ? path.resolve(process.cwd(), ctx.designPath)
+ : (LIVE_ROOTS?.designPath && fs.existsSync(LIVE_ROOTS.designPath) ? LIVE_ROOTS.designPath : null);
+ const hasProduct = ctx.hasProduct
+ || !!(LIVE_ROOTS?.productPath && fs.existsSync(LIVE_ROOTS.productPath));
+ return {
+ ...ctx,
+ hasProduct,
+ hasDesign: !!designPath,
+ resolvedDesignPath: designPath,
+ contextDir: ctx.contextDir || LIVE_ROOTS?.contextRoot || process.cwd(),
+ designContextDir: ctx.designContextDir
+ || (designPath ? path.dirname(designPath) : null),
+ };
+}
const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
+
+// The browser events allowed to mint a NEW session journal. `generate` starts
+// a variant session at Go; `steer` mints its own request id. Every other
+// id-carrying event must land on an existing session (see the unknown_session
+// gate in the /events handler).
+const SESSION_CREATING_EVENT_TYPES = new Set(['generate', 'steer']);
// The browser checkpoints for several unrelated reasons (see checkpointPayload
// in live-browser.js). Only these two report that variant availability changed,
// and only they may drive variant_progress / the *_reviewable phases.
-const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(['variants_progress', 'variants_ready']);
+const VARIANT_PROGRESS_CHECKPOINT_REASONS = new Set(VARIANT_PROGRESS_CHECKPOINT_REASON_LIST);
// ---------------------------------------------------------------------------
// Port detection
@@ -150,7 +182,16 @@ function chatAgentLikelyActive() {
const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
function enqueueEvent(event) {
- if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
+ if (!event) return;
+ // Dedupe by (session, type), except mount failures, which are per-variant:
+ // variant 2 failing must not be swallowed because variant 1's failure is
+ // still queued.
+ const duplicate = event.id && state.pendingEvents.some((entry) => (
+ entry.event?.id === event.id
+ && entry.event?.type === event.type
+ && (event.type !== 'variant_mount_failed' || entry.event?.variant === event.variant)
+ ));
+ if (duplicate) return;
state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
flushPendingPolls();
}
@@ -445,6 +486,11 @@ function summarizeActiveSessionForClient(snapshot = {}) {
generationCompletedAt: snapshot.generationCompletedAt ?? null,
generationCanceled: snapshot.generationCanceled === true,
cancelReason: snapshot.cancelReason ?? null,
+ // Render truth, so a browser with no localStorage can rehydrate to the
+ // same comparison the server already knows about.
+ mountedVariants: Array.isArray(snapshot.mountedVariants) ? snapshot.mountedVariants : [],
+ mountFailures: Array.isArray(snapshot.mountFailures) ? snapshot.mountFailures : [],
+ renderState: snapshot.renderState ?? null,
};
}
@@ -618,7 +664,7 @@ function hasProjectContext() {
// PRODUCT.md carries brand voice / anti-references — that's what determines
// whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
// concern, surfaced by the design panel's own empty state.
- return !!PROJECT_CONTEXT.hasProduct;
+ return !!resolveProjectContext().hasProduct;
}
function statOrNull(filePath) {
@@ -690,6 +736,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
port: state.port,
vocabulary: LIVE_COMMANDS,
commandPrefix: IMPECCABLE_COMMAND_PREFIX,
+ appRoot: process.cwd(),
parts,
});
res.writeHead(200, {
@@ -827,8 +874,9 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
- const mdPath = DESIGN_MD_PATH;
- const jsonPath = resolveDesignSidecarPath(process.cwd(), PROJECT_CONTEXT.designContextDir || CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
+ const projectContext = resolveProjectContext();
+ const mdPath = projectContext.resolvedDesignPath;
+ const jsonPath = resolveDesignSidecarPath(process.cwd(), projectContext.designContextDir || projectContext.contextDir) || getDesignSidecarPath(process.cwd());
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
@@ -979,6 +1027,20 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
res.end(JSON.stringify({ ok: true }));
return;
}
+ // Only the events that START a session may create its journal.
+ // Everything else (checkpoints, mount acks, accept/discard) must
+ // reference a session THIS store already knows: appendEvent creates a
+ // journal for any id it is handed, so without this gate a browser
+ // resuming another project's session from per-origin storage (two
+ // apps sharing a localhost port) materializes a ghost session here
+ // that keeps reattaching after every discard.
+ if (msg.id && state.sessionStore
+ && !SESSION_CREATING_EVENT_TYPES.has(msg.type)
+ && !state.sessionStore.has(msg.id)) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'unknown_session', id: msg.id }));
+ return;
+ }
const missedCompletion = detectMissedGenerationCompletion(msg);
if (state.sessionStore && msg.id) {
try {
@@ -997,7 +1059,25 @@ function createRequestHandler({ detectScript, liveScriptParts }) {
if (msg.type === 'exit') {
cleanupSvelteComponentSessionsBeforeExit();
}
- if (msg.type !== 'checkpoint') {
+ // An ORPHANED discard is the browser reporting that the session's
+ // wrapper no longer exists in source (edited or regenerated away).
+ // There is no cleanup for an agent to perform, and asking one to run
+ // the normal discard flow would just fail against the missing
+ // scaffolding, so the server terminalizes the session itself and the
+ // event stays out of the poll queue.
+ const orphanedDiscard = msg.type === 'discard' && msg.orphaned === true;
+ if (orphanedDiscard && state.sessionStore && msg.id) {
+ try {
+ state.sessionStore.appendEvent({ type: 'discarded', id: msg.id, orphaned: true });
+ } catch { /* the discard_requested phase already left the resumable set */ }
+ }
+ // `variant_mounted` is the happy path: it is journaled above so the
+ // snapshot carries render truth, but there is nothing for the agent to
+ // do about it, so it stays out of the poll queue and off the SSE bus.
+ // `variant_mount_failed` is the opposite: the agent published something
+ // the browser could not render, and only the agent can fix it, so it
+ // goes to the queue as a first-class event.
+ if (msg.type !== 'checkpoint' && msg.type !== 'variant_mounted' && !orphanedDiscard) {
enqueueEvent(msg);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -1099,7 +1179,8 @@ function sessionFileMetadataFromPollReply(file) {
const base = { file: normalized };
const metadataFile = normalized;
if (!metadataFile.endsWith('/manifest.json') && metadataFile !== 'manifest.json') return base;
- if (!metadataFile.includes('node_modules/.impeccable-live/')
+ if (!metadataFile.includes('.impeccable/live/previews/')
+ && !metadataFile.includes('node_modules/.impeccable-live/')
&& !metadataFile.includes('src/lib/impeccable/')
&& !metadataFile.includes('/.impeccable-live/')) return base;
@@ -1139,7 +1220,14 @@ function inferSourceEventType(msg = {}, pendingEvents = state.pendingEvents) {
// `agent_done` can be the automatic acknowledgement for a carbonize Accept.
// New pollers send sourceEventType explicitly; default to generate only for
// older callers so a late worker cannot acknowledge a queued Accept.
- if (msg.type === 'agent_done' || msg.type === 'done') return 'generate';
+ if (msg.type === 'agent_done' || msg.type === 'done') {
+ // A `done` reply to a mount failure is the republish that unblocks the
+ // browser. Without this the ack would look for a `generate` that was
+ // already retired, the mount-failure event would stay queued, and the next
+ // poll would hand the same failure back to the agent forever.
+ if (!pendingTypes.has('generate') && pendingTypes.has('variant_mount_failed')) return 'variant_mount_failed';
+ return 'generate';
+ }
// `error` is reference/live.md's documented failure reply, and parseReplyArgs
// never sets sourceEventType on it (the poller is a fresh process that cannot
// know what it leased). Returning undefined here makes acknowledgePendingEvent
@@ -1264,6 +1352,30 @@ function handlePollPost(req, res) {
return;
}
const replyFileMeta = sessionFileMetadataFromPollReply(msg.file);
+ // A publish (done reply carrying a component manifest) snapshots the
+ // variant files into a fresh revision dir before the browser is told:
+ // the import path changes every publish, so no transform cache can pin a
+ // stale compile of a republished module (node_modules is unwatched).
+ // Broken variants are bounced HERE, before the browser imports anything:
+ // a compile error that reaches the page is a red overlay in the user's
+ // face; bounced at publish it is a private fix with file and line.
+ if (replyFileMeta.previewMode === 'svelte-component'
+ && msg.id
+ && (msg.type === 'done' || !msg.type)) {
+ let compileCheck = { ok: true, failures: [] };
+ try { compileCheck = compileCheckVariants(msg.id, process.cwd()); } catch { /* best-effort */ }
+ if (!compileCheck.ok) {
+ res.writeHead(422, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ error: 'variant_compile_failed',
+ id: msg.id,
+ failures: compileCheck.failures,
+ _instructions: 'The publish was NOT delivered: the listed variant file(s) do not compile, so the browser never saw them. Fix each failure at the given file and line (the most common cause is a second top-level \n`;
}
+/**
+ * Scaffold a component-preview session. The scaffold is AST-based: the app's
+ * own svelte compiler parses the selected markup, control-flow blocks are
+ * preserved (an each collection crosses the prop contract as ONE structured
+ * prop, its loop body verbatim), and constructs a detached preview cannot
+ * support return `{ fallback: 'source-preview', reason }` so the caller keeps
+ * the markup inside the route file instead of shipping a wrong preview.
+ */
export function scaffoldSvelteComponentSession({
id,
count,
@@ -145,25 +191,55 @@ export function scaffoldSvelteComponentSession({
originalLines,
cwd = process.cwd(),
}) {
+ const originalMarkup = originalLines.join('\n');
+
+ const compiler = loadSvelteCompiler(cwd);
+ if (!compiler) {
+ return { fallback: 'source-preview', reason: 'svelte 5 compiler not resolvable from the app root' };
+ }
+ const analysis = analyzeSvelteMarkup(originalMarkup, compiler.parse);
+ if (!analysis.ok) {
+ return { fallback: 'source-preview', reason: analysis.reason };
+ }
+
ensureRuntimeHelper(cwd);
const dir = componentSessionDir(id, cwd);
fs.mkdirSync(dir, { recursive: true });
- const originalMarkup = originalLines.join('\n');
- const contract = buildPropContract(extractMustacheExpressions(originalMarkup));
- const originalWithProps = substituteExprsWithProps(originalMarkup, contract);
+ const contract = analysis.contract;
+ const seeded = extractMatchingSourceCss(
+ safeReadSource(path.resolve(cwd, sourceFile)),
+ originalMarkup,
+ );
+ const seededCss = seeded.css;
+ // The preview compiles in isolation, so NONE of these source rules applied
+ // to what the user approved. Accept enforces that preview truth: any of
+ // them the variant does not re-declare is superseded and removed, instead
+ // of re-attaching to the accepted markup through kept class names (the
+ // ".decisions grid grabs the new board" failure). Only the CLASS-matched
+ // selectors are candidates; tag rules style shared route elements.
+ const seededSelectors = [...seeded.supersedable];
const manifest = {
id,
previewMode: 'svelte-component',
+ contractVersion: 2,
sourceFile: sourceFile.split(path.sep).join('/'),
sourceStartLine,
sourceEndLine,
count,
propContract: contract,
originalMarkup,
+ seededSelectors,
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ // Absolute paths let the browser fall back to /@fs/ imports when the dev
+ // server's base or root makes root-relative URLs miss, and probe whether
+ // the preview tree is reachable at all before blaming a variant.
+ componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -171,7 +247,7 @@ export function scaffoldSvelteComponentSession({
for (let n = 1; n <= count; n++) {
const variantFile = path.join(dir, `v${n}.svelte`);
if (!fs.existsSync(variantFile)) {
- fs.writeFileSync(variantFile, buildVariantStub(n, originalWithProps, contract), 'utf-8');
+ fs.writeFileSync(variantFile, buildVariantStubV2(n, analysis.markupWithProps, contract, seededCss), 'utf-8');
}
}
@@ -180,9 +256,100 @@ export function scaffoldSvelteComponentSession({
manifestFile: path.relative(cwd, path.join(dir, 'manifest.json')).split(path.sep).join('/'),
componentDir: manifest.componentDir,
propContract: contract,
+ // Inlined so the generate event's scaffold payload carries the stub
+ // shape; the agent edits vN.svelte in place instead of spending reads on
+ // the manifest and stub files (or deleting and recreating them).
+ stubMarkup: analysis.markupWithProps,
+ seededCss,
};
}
+function safeReadSource(filePath) {
+ try { return fs.readFileSync(filePath, 'utf-8'); } catch { return ''; }
+}
+
+function escapeSelectorToken(token) {
+ return String(token).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * Seed variant stubs with the source component's rules that already style the
+ * selected markup, so variants start from the real cascade (a detached
+ * preview inherits none of the route's compile-scoped CSS) instead of
+ * reimplementing it blind.
+ *
+ * Returns { css, supersedable }. `css` is every matching rule (class OR tag
+ * matched). `supersedable` holds only the CLASS-matched selectors: those are
+ * the accept-time removal candidates. Tag selectors (h1, a, p) style shared
+ * elements across the whole route, so they seed the preview but are never
+ * candidates for removal.
+ */
+export function extractMatchingSourceCss(routeSource, originalMarkup) {
+ const empty = { css: '', supersedable: new Set() };
+ const styleMatch = String(routeSource || '').match(/\n`
+ : `\n\n`;
+ return `${buildPropsScriptV2(contract)}${propsComment}${markupWithProps.trim()}\n${css}`;
+}
+
export function scaffoldSvelteComponentInsertSession({
id,
count,
@@ -213,7 +380,11 @@ export function scaffoldSvelteComponentInsertSession({
count,
propContract: [],
componentDir: path.relative(cwd, dir).split(path.sep).join('/'),
+ componentDirAbs: dir.split(path.sep).join('/'),
runtimeModule: `/${SVELTE_RUNTIME_FILE}`,
+ runtimeModuleAbs: path.join(cwd, SVELTE_RUNTIME_FILE).split(path.sep).join('/'),
+ probeModule: `/${SVELTE_PROBE_FILE}`,
+ probeModuleAbs: path.join(cwd, SVELTE_PROBE_FILE).split(path.sep).join('/'),
};
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
@@ -238,16 +409,24 @@ export function findSvelteComponentManifest(id, cwd = process.cwd()) {
if (fs.existsSync(direct)) {
return readManifest(direct);
}
- const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
- if (!fs.existsSync(root)) return null;
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- const candidate = path.join(root, entry.name, 'manifest.json');
- if (!fs.existsSync(candidate)) continue;
- try {
- const manifest = readManifest(candidate);
- if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
- } catch { /* skip */ }
+ // Legacy location: a session scaffolded by an older version can still be
+ // accepted after an upgrade.
+ const legacyDirect = path.join(cwd, LEGACY_SVELTE_COMPONENT_ROOT, id, 'manifest.json');
+ if (fs.existsSync(legacyDirect)) {
+ return readManifest(legacyDirect);
+ }
+ for (const rootRel of [SVELTE_COMPONENT_ROOT, LEGACY_SVELTE_COMPONENT_ROOT]) {
+ const root = path.join(cwd, rootRel);
+ if (!fs.existsSync(root)) continue;
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ const candidate = path.join(root, entry.name, 'manifest.json');
+ if (!fs.existsSync(candidate)) continue;
+ try {
+ const manifest = readManifest(candidate);
+ if (manifest?.id === id) return { ...manifest, manifestPath: candidate };
+ } catch { /* skip */ }
+ }
}
return null;
}
@@ -451,35 +630,6 @@ function rewriteParamSelectors(selector, paramValues) {
return { keep, selector: next };
}
-function splitSelectorList(prelude) {
- const selectors = [];
- let start = 0;
- let bracket = 0;
- let paren = 0;
- let quote = null;
- for (let i = 0; i < prelude.length; i++) {
- const ch = prelude[i];
- if (quote) {
- if (ch === '\\') i++;
- else if (ch === quote) quote = null;
- continue;
- }
- if (ch === '"' || ch === "'") {
- quote = ch;
- continue;
- }
- if (ch === '[') bracket++;
- else if (ch === ']') bracket = Math.max(0, bracket - 1);
- else if (ch === '(') paren++;
- else if (ch === ')') paren = Math.max(0, paren - 1);
- else if (ch === ',' && bracket === 0 && paren === 0) {
- selectors.push(prelude.slice(start, i));
- start = i + 1;
- }
- }
- selectors.push(prelude.slice(start));
- return selectors;
-}
function selectorHasVariant(selector, variantNum) {
return variantSelectorRegex(variantNum).test(selector);
@@ -527,10 +677,24 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
const rootTag = matchOpeningTag(markup)?.tag || 'div';
const contract = manifest.propContract || [];
+ const compiler = loadSvelteCompiler(cwd);
const mergedMarkup = mergeOriginalTopLevelAttrs(markup, manifest.originalMarkup || '');
- const restoredMarkup = substitutePropsWithExprs(mergedMarkup, contract)
- .split('\n')
- .map((line) => line.trimEnd());
+
+ // Restore props back to route expressions. Contract v2 restores through the
+ // AST so a prop used without braces (each headers, attribute positions)
+ // still maps back to its original expression; v1 falls back to the textual
+ // placeholder swap.
+ let restoredText;
+ if (Number(manifest.contractVersion) === 2 && compiler) {
+ const restored = restoreSvelteMarkup(mergedMarkup, contract, compiler.parse);
+ if (!restored.ok) {
+ return { handled: false, error: 'Accepted variant does not parse: ' + restored.reason, ...resultBase };
+ }
+ restoredText = restored.markup;
+ } else {
+ restoredText = substitutePropsWithExprs(mergedMarkup, contract);
+ }
+ const restoredMarkup = restoredText.split('\n').map((line) => line.trimEnd());
const sourceContent = fs.readFileSync(sourceFile, 'utf-8');
const sourceLines = sourceContent.split('\n');
@@ -541,10 +705,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
}
const indent = sourceLines[start].match(/^(\s*)/)?.[1] || '';
- const indentedMarkup = restoredMarkup.map((line) => {
- if (line.trim() === '') return '';
- return indent + line.trimStart();
- });
+ const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, start),
@@ -552,25 +713,235 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
...sourceLines.slice(end + 1),
];
- const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
- const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
- if (bakedCss.length > 0) {
- newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ // Selectors that were already unused before this accept are the user's
+ // pre-existing code; the pruning pass must not touch them.
+ const preUnused = compiler ? collectUnusedSelectors(sourceContent, compiler.compile) : new Set();
+
+ // Bake params (declared kinds from params.json drive branch pruning), then
+ // MERGE into the component's existing style block: matching selectors are
+ // replaced, new ones appended. Appending alone is how superseded rules used
+ // to survive their own replacement.
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ // Defensive: strip preview-wrapper selectors that authoring rules forbid
+ // on this path but an off-spec agent may still emit.
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] };
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
+ cssStats.replaced = merged.replaced;
+ cssStats.appended = merged.appended;
+ }
+
+ let finalText = newLines.join('\n');
+
+ // Preview truth: the detached preview never applied the source rules that
+ // styled the replaced selection, so the user approved a design without
+ // them. Any seeded selector the variant did not re-declare is superseded;
+ // left in place it re-attaches through kept class names (the accepted root
+ // keeps its original classes) and re-layouts markup it no longer owns.
+ //
+ // Removal is bounded by ownership: a selector whose classes are still used
+ // by route markup OUTSIDE the replaced region does not belong to the pick
+ // alone, and removing it would strip styling from markup this accept never
+ // touched. Keeping it risks a visible re-attachment quirk on the accepted
+ // region; deleting it breaks the rest of the route. Keep it.
+ const outsideMarkup = [...sourceLines.slice(0, start), ...sourceLines.slice(end + 1)]
+ .join('\n')
+ .replace(/`;
+ return {
+ text: text.slice(0, lastMatch.index) + rebuilt + text.slice(lastMatch.index + lastMatch[0].length),
+ removed,
+ };
+}
+
+export function findLostSelectors(beforeSource, afterSource, prunedSelectors = []) {
+ const before = collectAllSelectors(styleBlockText(beforeSource));
+ const after = collectAllSelectors(styleBlockText(afterSource));
+ const pruned = new Set((prunedSelectors || []).map((s) => normalizeSelector(s)));
+ const lost = [];
+ for (const selector of before) {
+ if (!after.has(selector) && !pruned.has(selector)) lost.push(selector);
+ }
+ return lost;
+}
+
+function readDeclaredParams(manifest, variantNum, cwd) {
+ try {
+ const raw = JSON.parse(fs.readFileSync(path.join(cwd, manifest.componentDir, 'params.json'), 'utf-8'));
+ const list = raw?.[String(variantNum)];
+ return Array.isArray(list) ? list : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Merge CSS into a svelte component's top-level style block (created when
+ * absent), replacing rules whose selectors match and appending the rest.
+ */
+export function mergeCssIntoSvelteSource(sourceText, incomingCss) {
+ const text = String(sourceText || '');
+ const styleRe = /\n`,
+ replaced,
+ appended,
+ };
+ }
+
+ const inner = lastMatch[1];
+ const { css, replaced, appended } = reconcileCss(inner, incomingCss);
+ const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1);
+ const replacedBlock = `${openTag}\n${indentCssBlock(css)}\n`;
+ return {
+ text: text.slice(0, lastMatch.index) + replacedBlock + text.slice(lastMatch.index + lastMatch[0].length),
+ replaced,
+ appended,
+ };
+}
+
+function indentCssBlock(css) {
+ return String(css || '')
+ .split('\n')
+ .map((line) => (line.trim() === '' ? '' : ' ' + line))
+ .join('\n');
+}
+
function inlineSvelteComponentInsertAccept({
manifest,
markup,
@@ -601,10 +972,7 @@ function inlineSvelteComponentInsertAccept({
const nearbyLine = sourceLines[insertIndex] ?? sourceLines[insertIndex - 1] ?? '';
const indent = nearbyLine.match(/^(\s*)/)?.[1] || '';
- const indentedMarkup = restoredMarkup.map((line) => {
- if (line.trim() === '') return '';
- return indent + line.trimStart();
- });
+ const indentedMarkup = reindentPreservingStructure(restoredMarkup, indent);
let newLines = [
...sourceLines.slice(0, insertIndex),
@@ -612,10 +980,15 @@ function inlineSvelteComponentInsertAccept({
...sourceLines.slice(insertIndex),
];
- const sanitizedCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag);
- const bakedCss = bakeParamValuesInCss(sanitizedCss, paramValues);
- if (bakedCss.length > 0) {
- newLines = appendCssToSvelteStyle(newLines, bakedCss);
+ let variantCss = cssLines.join('\n');
+ if (/data-impeccable-variant|impeccable-variant-ready/.test(variantCss)) {
+ variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
+ }
+ const declaredParams = readDeclaredParams(manifest, variantNum, cwd);
+ const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
+ if (bakedCss.trim()) {
+ const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
+ newLines = merged.text.split('\n');
}
try {
@@ -625,8 +998,10 @@ function inlineSvelteComponentInsertAccept({
}
removeSvelteComponentSession(manifest.id, cwd);
+ const verify = verifyAcceptedSource(newLines.join('\n'));
return {
handled: true,
+ verify,
...resultBase,
};
}
@@ -729,18 +1104,159 @@ export function removeSvelteComponentSession(id, cwd = process.cwd()) {
} catch { /* non-fatal */ }
}
-export function removeAllSvelteComponentSessions(cwd = process.cwd()) {
- const root = path.join(cwd, SVELTE_COMPONENT_ROOT);
- if (!fs.existsSync(root)) return;
- for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- if (entry.name.startsWith('__')) continue;
+/**
+ * Compile-check every variant component of a session with the app's own
+ * compiler, BEFORE the browser ever imports them. A variant that does not
+ * compile (the classic: a second top-level `;
+ const { source, removed } = pruneUnusedSelectors(component, compile);
+ assert.equal(removed.includes('.gone'), true);
+ assert.equal(removed.includes('.alsogone'), true);
+ assert.doesNotMatch(source, /color: blue/);
+ assert.doesNotMatch(source, /\.alsogone/);
+ assert.match(source, /\.kept \{ color: red; \}/);
+ assert.match(source, /\.kept \.inner \{ font-weight: bold; \}/);
+ assert.match(source, /\.kept \{ margin: 0; \}/);
+ // The pruned result still compiles without unused-selector warnings.
+ const { warnings } = compile(source, { generate: false });
+ assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
+ });
+
+ it('never throws on uncompilable input', () => {
+ const { source } = pruneUnusedSelectors('
{broken');
+ });
+
+ it('prunes past child combinators without truncating the selector list', () => {
+ // The prelude walk must not treat the child combinator as a boundary:
+ // cutting at the `>` of `.wrap > .item` used to rewrite the list from
+ // mid-prelude.
+ const component = `
x
\n`;
+ const { source, removed } = pruneUnusedSelectors(component, compile);
+ assert.deepEqual(removed, ['.orphan']);
+ assert.match(source, /\.wrap > \.item \{ font-weight: bold; \}/);
+ assert.match(source, /\.wrap \{ padding: 4px; \}/);
+ const { warnings } = compile(source, { generate: false });
+ assert.deepEqual(warnings.filter((w) => w.code === 'css_unused_selector'), []);
+ });
+
+ it('removes a fully unused combinator rule without leaving a dangling fragment', () => {
+ // The corruption shape: after a mid-prelude cut, every remaining fragment
+ // equals the flagged selector, so the whole-rule branch deleted from the
+ // cut point and left `.wrap >` dangling in source.
+ const component = `
',
+ '.a { gap: var(--p-depth, 4px); }',
+ '.b[data-p-flag] { color: red; }',
+ ].join('\n');
+ const { clean, findings } = verifyAcceptedSource(dirty);
+ assert.equal(clean, false);
+ assert.equal(findings.length >= 3, true);
+ });
+});
diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs
index 2106bc1a6..4811f20c4 100644
--- a/tests/live-browser-regression.test.mjs
+++ b/tests/live-browser-regression.test.mjs
@@ -1082,6 +1082,257 @@ describe('live-browser.js regression guards', () => {
);
});
+ it('acknowledges every successful component mount back to the server', () => {
+ // `arrivedVariants` counts what the agent published. Without this ack the
+ // server has no way to tell "the user is comparing three variants" from
+ // "three modules 404'd and the page is blank".
+ assert.match(
+ SOURCE,
+ /reportVariantMounted\(sessionId, variantNum, moduleUrl\);/,
+ 'a successful mount must post variant_mounted so the journal carries render truth',
+ );
+ assert.match(
+ SOURCE,
+ /function reportVariantMounted\([\s\S]{0,400}?sendEvent\(\{\s*type: 'variant_mounted',/,
+ 'variant_mounted must go through the normal event POST helper',
+ );
+ });
+
+ it('reports mount failures to the server instead of only logging them', () => {
+ assert.match(
+ SOURCE,
+ /console\.error\('\[impeccable\] Failed to mount component variant[\s\S]{0,200}?reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);/,
+ 'event=live_browser.silent_mount_failure actor=browser operation=mount_component_variant risk=agent_never_learns_render_failed expected=variant_mount_failed posted actual=console.error only',
+ );
+ assert.match(
+ SOURCE,
+ /function reportVariantMountFailed\([\s\S]{0,900}?sendEvent\(\{ type: 'variant_mount_failed', id: sessionId, variant, url, error: message \}\);/,
+ 'variant_mount_failed must carry the failed module URL and the error text',
+ );
+ // Both the first mount and a variant switch route through this one catch,
+ // so the switch path can no longer revert with zero feedback.
+ assert.match(
+ SOURCE,
+ /reportVariantMountFailed\(sessionId, variantNum, moduleUrl, err\);[\s\S]{0,300}?showMountErrorCard\(sessionId, \{/,
+ 'a failed mount must raise the persistent error card from the shared catch',
+ );
+ });
+
+ it('keeps the session alive when a component variant fails to mount', () => {
+ const abort = SOURCE.match(
+ /function abortSvelteComponentInjection\(sessionId, details\) \{[\s\S]*?\n \}\n/,
+ );
+ assert.ok(abort, 'abortSvelteComponentInjection must still exist');
+ const body = abort[0];
+ assert.doesNotMatch(
+ body,
+ /clearSession\(\)/,
+ 'event=live_browser.mount_failure_wipe actor=browser operation=abort_svelte_injection risk=durable_session_orphaned expected=localStorage preserved actual=clearSession() called',
+ );
+ assert.doesNotMatch(
+ body,
+ /currentSessionId = null/,
+ 'the session id is the only handle Retry and a republish have; abort must not drop it',
+ );
+ assert.doesNotMatch(
+ body,
+ /setLiveState\('PICKING'\)/,
+ 'a mount failure is not the end of the session, so the bar must not fall back to PICKING',
+ );
+ assert.doesNotMatch(
+ body,
+ /showToast\(/,
+ 'the 5s toast was replaced by a persistent card; a toast that vanishes reads as no feedback at all',
+ );
+ assert.match(body, /showMountErrorCard\(/, 'abort must raise the persistent error card');
+ assert.match(body, /saveSession\(\)/, 'abort must keep the localStorage cache in step with the server');
+ });
+
+ it('gives the manifest fetch failure and the session mismatch the same error card', () => {
+ assert.doesNotMatch(
+ SOURCE,
+ /if \(manifest\.id !== sessionId\) return;/,
+ 'event=live_browser.silent_manifest_mismatch actor=browser operation=inject_from_manifest risk=bar_stuck_in_generating expected=error card actual=bare return',
+ );
+ assert.match(
+ SOURCE,
+ /if \(manifest\.id !== sessionId\) \{[\s\S]{0,700}?showMountErrorCard\(sessionId, \{/,
+ 'a manifest belonging to another session must surface, not disappear',
+ );
+ assert.match(
+ SOURCE,
+ /Failed to mount component-preview variants:[\s\S]{0,400}?reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, manifestPath, err\);/,
+ 'a manifest that cannot be read is a render failure the agent must hear about',
+ );
+ assert.doesNotMatch(
+ SOURCE,
+ /reportVariantMountFailed\(sessionId, visibleVariant \|\| 1, url,/,
+ 'the /source fetch URL carries the live token and must never be journaled; report the manifest path',
+ );
+ });
+
+ it('offers a retry that re-runs injection for the same session', () => {
+ assert.match(
+ SOURCE,
+ /function retryMountErrorCard\(\) \{[\s\S]{0,900}?injectSvelteComponentsFromManifest\(manifestPath, sessionId\);/,
+ 'the Retry button must re-enter the normal injection path rather than restarting the session',
+ );
+ assert.match(
+ SOURCE,
+ /function truncateMiddle\(value, max\)/,
+ 'the card shows the failed module URL truncated in the middle so both ends stay readable',
+ );
+ });
+
+ it('rehydrates from the server when localStorage has no session', () => {
+ assert.doesNotMatch(
+ SOURCE,
+ /function restoreSessionWithoutWrapper\(reason, activeSessions\) \{\s*const saved = loadSession\(\);/,
+ 'event=live_browser.storage_gated_restore actor=browser operation=sse_connected risk=durable_session_unreachable expected=server summary can seed a restore actual=localStorage is a gate',
+ );
+ assert.match(
+ SOURCE,
+ /const adopted = cached\?\.id \? null : findAdoptableServerSession\(activeSessions\);/,
+ 'a page with no cached session must be able to adopt the durable one the server reports',
+ );
+ assert.match(
+ SOURCE,
+ /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?&& session\.pageUrl\s*&& pageMatchesCurrent\(session\.pageUrl\)/,
+ 'adoption must require an explicit pageUrl match so it cannot hijack an unrelated route',
+ );
+ assert.match(
+ SOURCE,
+ /function findAdoptableServerSession\(activeSessions\) \{[\s\S]{0,600}?!isTerminalSessionSummary\(session\)/,
+ 'accepted, discarded, and completed sessions must never be adopted',
+ );
+ });
+
+ it('carries no agent phase the server cannot emit', () => {
+ // Every one of these lived in PHASE_RANK and, for most, in a status string
+ // the user could never see: recordAgentPhase() in live-server.mjs has
+ // never emitted them. The validator now rejects them outright, so a
+ // leftover branch here is a branch that cannot run.
+ for (const retired of [
+ 'first_variant_generating',
+ 'first_variant_validating',
+ 'remaining_variants_generating',
+ 'remaining_variants_validating',
+ 'variant_parameters_generating',
+ 'variant_parameters_validating',
+ 'parameters_ready',
+ ]) {
+ assert.doesNotMatch(
+ SOURCE,
+ new RegExp(retired),
+ 'event=live_browser.dead_agent_phase actor=browser operation=phase_rank risk=ui_branches_on_phase_nothing_sends phase=' + retired,
+ );
+ }
+ // The phases the server does emit must all still rank.
+ for (const live of [
+ 'picked_up', 'scaffolding', 'scaffold_fallback', 'source_ready',
+ 'generation_ready', 'first_reviewable', 'second_reviewable', 'all_variants_ready',
+ ]) {
+ assert.match(SOURCE, new RegExp('\\n\\s+' + live + ': \\d+,'), live + ' must keep a rank');
+ }
+ });
+
+ it('renders the cycling counter through one function with one denominator', () => {
+ // buildCyclingRow showed visibleVariant/expectedVariants while
+ // syncCyclingControls wrote shown/arrivedVariants, so the same unchanged
+ // state rendered as "2/3" or "2/2" depending on which path ran last.
+ assert.doesNotMatch(
+ SOURCE,
+ /visibleVariant \+ '\/' \+ expectedVariants/,
+ 'event=live_browser.counter_drift actor=browser operation=cycling_counter risk=two_denominators_for_one_counter',
+ );
+ assert.doesNotMatch(SOURCE, /shown \+ '\/' \+ arrivedVariants/);
+ assert.match(
+ SOURCE,
+ /function cyclingCounterText\(\) \{[\s\S]{0,300}?arrivedVariants > 0 \? arrivedVariants : expectedVariants/,
+ 'the denominator policy is arrived-when-known, expected otherwise, in one place',
+ );
+ const counterAssignments = SOURCE.match(/-variant-counter'\)?;?[\s\S]{0,120}?textContent = ([^;]+);/g) || [];
+ for (const assignment of counterAssignments) {
+ assert.match(assignment, /cyclingCounterText\(\)/, 'every counter write goes through cyclingCounterText');
+ }
+ });
+
+ it('gives the steer bar a visible Send control alongside Enter', () => {
+ assert.match(
+ SOURCE,
+ /pageChatSendBtn = el\('button'/,
+ 'event=live_browser.steer_send_affordance actor=user operation=steer risk=enter_only_submit_reads_as_dead_input',
+ );
+ assert.match(SOURCE, /pageChatSendBtn\.id = PREFIX \+ '-page-chat-send'/);
+ assert.match(
+ SOURCE,
+ /function syncPageChatSendButton\(\)[\s\S]{0,900}?pageChatSendBtn\.disabled = !visible \|\| !hasText/,
+ 'Send is disabled with an empty input',
+ );
+ assert.match(
+ SOURCE,
+ /function syncPageChatSendButton\(\)[\s\S]{0,900}?const visible = !steerLocked/,
+ 'Send disappears while a steer is in flight',
+ );
+ // Enter must still submit.
+ assert.match(
+ SOURCE,
+ /if \(e\.key === 'Enter'\) \{\s*e\.preventDefault\(\);\s*submitSteerMessage\(\);/,
+ 'keyboard submit stays',
+ );
+ assert.match(
+ SOURCE,
+ /PREFIX \+ '-page-chat-send'\] \}/,
+ 'the Send control must be registered as live UI chrome so it is excluded from capture',
+ );
+ });
+
+ it('says a queued steer is queued instead of pulsing at the user', () => {
+ assert.match(
+ SOURCE,
+ /function steerQueuedBehindGeneration\(\) \{[\s\S]{0,220}?steerLocked && !agentPollingConnected && agentHasWorkInFlight\(\)/,
+ 'event=live_browser.steer_queue_feedback actor=user operation=steer_during_generation risk=queued_request_reads_as_lost',
+ );
+ assert.match(SOURCE, /Queued behind current generation/);
+ assert.match(
+ SOURCE,
+ /function syncSteerQueueHint\(\)[\s\S]{0,400}?pageChatDotsEl\.style\.display = 'none'/,
+ 'the queue hint replaces the bare dots rather than sitting beside them',
+ );
+ assert.match(SOURCE, /function syncAgentPollingUi\(connected\) \{[\s\S]{0,140}?syncSteerQueueHint\(\)/);
+ });
+
+ it('names the actual cause when a steer times out', () => {
+ assert.doesNotMatch(
+ SOURCE,
+ /Check that live-poll is running and replies with steer_done/,
+ 'event=live_browser.steer_timeout_copy actor=user operation=steer_timeout risk=blames_live_poll_for_a_busy_agent',
+ );
+ assert.match(
+ SOURCE,
+ /function steerTimeoutMessage\(\)[\s\S]{0,900}?steerQueuedBehindGeneration\(\)[\s\S]{0,400}?!agentPollingConnected/,
+ 'the timeout message branches on agent-busy vs nobody-polling',
+ );
+ });
+
+ it('distinguishes an empty DESIGN.md from a missing one in the design panel', () => {
+ assert.match(
+ SOURCE,
+ /function designEmptyMessage\(\)[\s\S]{0,700}?designState\.hasMd && !designState\.hasSidecar[\s\S]{0,300}?DESIGN\.md found, no structured tokens to display/,
+ 'event=live_browser.design_empty_state actor=user operation=open_design_panel risk=present_design_md_reported_as_missing',
+ );
+ assert.match(
+ SOURCE,
+ /const beforeCount = body\.childElementCount;/,
+ 'the empty check must ignore the stale hint and CTA the caller already appended',
+ );
+ assert.doesNotMatch(
+ SOURCE,
+ /msgDiv\('empty', 'No design system data available\.'\)/,
+ 'the bare message may only survive as the genuinely-absent branch of designEmptyMessage',
+ );
+ });
+
it('editing focus timeout does not read a stale inline edit row', () => {
assert.doesNotMatch(
SOURCE,
@@ -1094,4 +1345,28 @@ describe('live-browser.js regression guards', () => {
'edit-mode delayed focus should capture the element before scheduling and no-op if editing ended before the timeout fires',
);
});
+ it('adopts only variant comparisons from the server, never steer or manual sessions', () => {
+ // A completed-steer session is non-terminal and carries a sourceFile;
+ // adopting it as a comparison hunts for a variant wrapper that never
+ // existed and wedges the bar before the user's next pick (found by the
+ // vite8-react-base-path e2e after server-first rehydration landed).
+ assert.match(
+ SOURCE,
+ /function findAdoptableServerSession\([\s\S]{0,900}?Number\(session\.expectedVariants\) > 0/,
+ 'adoption must require a generation-shaped session (expectedVariants > 0)',
+ );
+ assert.match(
+ SOURCE,
+ /function findAdoptableServerSession\([\s\S]{0,900}?ADOPTABLE_SESSION_PHASES\.has\(String\(session\.phase/,
+ 'adoption must be limited to the comparison-phase allowlist',
+ );
+ // Accept/carbonize phases are agent-side work; adopting them resurrects
+ // the bar over a decided comparison (the slow-CI astro accept hang).
+ assert.doesNotMatch(
+ SOURCE,
+ /ADOPTABLE_SESSION_PHASES = new Set\(\[[\s\S]{0,200}?(accept_requested|carbonize|steer|manual_edit)/,
+ 'accept, carbonize, steer, and manual-edit phases must not be adoptable',
+ );
+ });
+
});
diff --git a/tests/live-e2e-llm-agent.test.mjs b/tests/live-e2e-llm-agent.test.mjs
index 3ec82f3b5..193178d89 100644
--- a/tests/live-e2e-llm-agent.test.mjs
+++ b/tests/live-e2e-llm-agent.test.mjs
@@ -20,14 +20,23 @@ import {
} from './live-e2e/agents/llm-agent.mjs';
describe('live-e2e LLM agent provider config', () => {
- it('defaults to Anthropic and Claude Haiku when no keys are present', () => {
+ it('defaults to OpenAI gpt-5.6-terra at medium reasoning effort', () => {
const config = resolveLlmAgentConfig({}, {});
+ assert.equal(config.provider, 'openai');
+ assert.equal(config.model, 'gpt-5.6-terra');
+ assert.equal(config.reasoningEffort, 'medium');
+ assert.equal(config.requiredEnv, 'OPENAI_API_KEY');
+ assert.equal(config.apiKey, undefined);
+ assert.equal(config.baseURL, undefined);
+ });
+
+ it('still resolves Anthropic when explicitly selected', () => {
+ const config = resolveLlmAgentConfig({}, { IMPECCABLE_E2E_LLM_PROVIDER: 'anthropic', ANTHROPIC_API_KEY: 'k' });
+
assert.equal(config.provider, 'anthropic');
assert.equal(config.model, 'claude-haiku-4-5');
assert.equal(config.requiredEnv, 'ANTHROPIC_API_KEY');
- assert.equal(config.apiKey, undefined);
- assert.equal(config.baseURL, undefined);
});
it('prefers Anthropic when both provider keys are present', () => {
diff --git a/tests/live-e2e.test.mjs b/tests/live-e2e.test.mjs
index a6be62570..3cf06de1a 100644
--- a/tests/live-e2e.test.mjs
+++ b/tests/live-e2e.test.mjs
@@ -22,11 +22,15 @@
import { describe, it, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
-import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
-import { createFakeAgent } from './live-e2e/agent.mjs';
+import {
+ createFakeAgent,
+ republishSvelteComponentVariants,
+ FAKE_VARIANT_FONT_WEIGHTS,
+} from './live-e2e/agent.mjs';
import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs';
import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
import {
@@ -34,11 +38,14 @@ import {
assertApplyDockLoading,
assertAnnotationUploadEvent,
assertSourceApplied,
+ chooseTuneStep,
clickExitLiveMode,
+ cycleToVariant,
clickAccept,
clickApplyEdits,
clickEditCopy,
clickDiscard,
+ clickMountRetry,
clickSaveEdit,
clickGo,
clickNext,
@@ -47,11 +54,18 @@ import {
drawAnnotationPinAndStroke,
getVisibleVariant,
installLiveQueryHelpers,
+ isMountErrorCardVisible,
+ openTunePanel,
pickElement,
+ readComputedFontWeight,
runLiveChromeBottomBarSmoke,
+ setTuneRange,
waitForApplyDockHidden,
waitForBarHidden,
+ waitForComputedFontWeight,
waitForCycling,
+ waitForMountErrorCard,
+ waitForMountErrorCardGone,
runInsertFlow,
waitForHandshake,
} from './live-e2e/ui.mjs';
@@ -130,6 +144,33 @@ function shouldRunScenario(name) {
return scenarioNames.size === 0 || scenarioNames.has('all') || scenarioNames.has(name);
}
+// Anything served out of the live preview / runtime tree. A 404 here is never
+// benign: it means the variant preview module or its runtime never loaded, and
+// the flow silently fell back to the untouched original.
+const LIVE_TREE_URL_RE = /\.impeccable-live|impeccable\/live\/preview/i;
+
+// The two framework dev-mode notices every React fixture emits.
+const FRAMEWORK_NOISE_RE = /Download the React DevTools|StrictMode/i;
+
+// Chromium's resource-failure console line. Only the favicon flavour is
+// allowlisted; a favicon is not part of any fixture and its absence proves
+// nothing about live mode.
+const RESOURCE_404_RE = /Failed to load resource: the server responded with a status of 404/i;
+const FAVICON_URL_RE = /favicon(\.[a-z0-9]+)?(\?|$)|\/favicon/i;
+
+function isLiveTreeUrl(url) {
+ return LIVE_TREE_URL_RE.test(String(url || ''));
+}
+
+function isBenignConsoleError(entry) {
+ const text = String(entry || '');
+ // Live-tree failures are never allowlisted, whatever else they match.
+ if (LIVE_TREE_URL_RE.test(text)) return false;
+ if (FRAMEWORK_NOISE_RE.test(text)) return true;
+ if (RESOURCE_404_RE.test(text)) return FAVICON_URL_RE.test(text);
+ return false;
+}
+
before(async () => {
if (fixtures.length === 0) return;
try {
@@ -216,7 +257,11 @@ for (const { name, fixture } of fixtures) {
log: (m) => t.diagnostic(m),
});
- const { page, tmp, consoleErrors, teardown } = session;
+ // `tmp` is the staged repo root; `appRoot` is the directory the dev
+ // server serves. They differ only for fixtures declaring
+ // `runtime.appDir`. Every fixture-relative source path resolves against
+ // appRoot — the repo root may not contain a single source file.
+ const { page, tmp, appRoot, consoleErrors, teardown } = session;
const expectedCount = 3;
const isInsert = fixture.runtime.mode === 'insert';
const insertCfg = fixture.runtime.insert || {};
@@ -228,15 +273,25 @@ for (const { name, fixture } of fixtures) {
? insertDomSelector
: pickSelector;
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
- const variantContentSelector = isInsert
- ? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
+ // Component previews mount every variant into the same node, so one
+ // selector serves all three; the HTML/JSX paths keep a div per variant.
+ const variantContentSelectorFor = (variantNum) => (isInsert
+ ? (usesSvelteComponentPreview ? '.inserted-copy' : `[data-impeccable-variant="${variantNum}"] .inserted-copy`)
: usesSvelteComponentPreview
? pickSelector
- : '[data-impeccable-variant="2"] > :first-child';
+ : `[data-impeccable-variant="${variantNum}"] > :first-child`);
let stateProbeBaseline = null;
let sourceFile = null;
try {
+ // 0. Root resolution — only for fixtures whose app is not the repo
+ // root. live.mjs booted from the repo root and had to find the app
+ // on its own; everything after this depends on it having done so.
+ if (session.appDir) {
+ t.diagnostic(`Asserting resolved roots for nested app ${session.appDir}/`);
+ assertNestedAppRoots(session);
+ }
+
// 1. Handshake
t.diagnostic('Waiting for live handshake');
await waitForHandshake(page);
@@ -255,7 +310,7 @@ for (const { name, fixture } of fixtures) {
const steerTimeouts = agentMode === 'llm'
? { unlockTimeoutMs: 90_000, selectorTimeoutMs: 45_000, runPreActions }
: { runPreActions };
- await runSteerSmoke(page, tmp, fixture, (m) => t.diagnostic(m), steerTimeouts);
+ await runSteerSmoke(page, appRoot, fixture, (m) => t.diagnostic(m), steerTimeouts);
}
// 2. preActions — fixtures with hidden/conditional content (modals,
@@ -278,7 +333,7 @@ for (const { name, fixture } of fixtures) {
});
} else {
t.diagnostic(`Picking ${pickSelector}`);
- await pickElement(page, pickSelector);
+ await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
if (process.env.IMPECCABLE_E2E_DEBUG) {
const barText = await page.evaluate(() => {
@@ -317,14 +372,20 @@ for (const { name, fixture } of fixtures) {
}
// 5. Source-side check: wrapper + style + variants are present
- sourceFile = await locateSessionFile(tmp);
+ sourceFile = await locateSessionFile(appRoot);
+ if (session.appDir) {
+ assert.ok(
+ relative(tmp, sourceFile).startsWith(session.appDir + '/'),
+ `session source must live under ${session.appDir}/, got ${relative(tmp, sourceFile)}`,
+ );
+ }
const after = readFileSync(sourceFile, 'utf-8');
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
if (svelteComponentSession) {
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
- const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
+ const variantFile = join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
const variantBody = readFileSync(variantFile, 'utf-8');
- const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
+ const routeBody = readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8');
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
if (isInsert) {
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
@@ -351,14 +412,14 @@ for (const { name, fixture } of fixtures) {
}
if (insertCfg.assertAnchorContains) {
const anchorSource = svelteComponentSession
- ? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8')
+ ? readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8')
: after;
assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched');
}
}
if (svelteComponentSession) {
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
- assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /\n`;
+}
+
+/**
+ * Re-author every variant of a live component session and tell the server the
+ * publish happened, exactly as the poll loop does after a generate. The server
+ * snapshots a fresh `r/` revision dir on the `done` reply, so the browser
+ * imports from a path it has never seen and cannot serve a cached compile of
+ * the previous content.
+ *
+ * @param {object} opts
+ * @param {string} opts.tmp app root (where the manifest path resolves)
+ * @param {string} opts.manifestFile manifest path relative to `tmp`
+ * @param {{port: number, token: string}} opts.live
+ * @param {(variantNum: number, shape: object) => string} opts.css
+ */
+export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) {
+ const manifestPath = path.join(tmp, manifestFile);
+ const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
+ const componentDir = path.join(tmp, manifest.componentDir);
+ const shape = svelteSelectionShape(manifest.originalMarkup || '');
+ const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
+ for (let variantNum = 1; variantNum <= count; variantNum++) {
+ const file = path.join(componentDir, `v${variantNum}.svelte`);
+ let source;
+ try { source = await fs.readFile(file, 'utf-8'); } catch { continue; }
+ await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8');
+ }
+ const res = await fetch(`http://127.0.0.1:${live.port}/poll`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ token: live.token,
+ type: 'done',
+ sourceEventType: 'generate',
+ id: manifest.id,
+ file: manifestFile,
+ }),
+ });
+ if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`);
+ return { manifest, shape };
+}
+
export function insertTargetFromEvent(event) {
const anchor = event?.insert?.anchor || {};
const classes = Array.isArray(anchor.classes)
@@ -352,6 +559,74 @@ function attrEscape(str, { svelte = false } = {}) {
return s;
}
+/**
+ * JSX-source counterpart to the Svelte prop contract.
+ *
+ * When the picked element's source content is a bare JSX expression (or text
+ * mixed with expressions), return it verbatim so the variant markup keeps the
+ * binding instead of the rendered snapshot. Returns null for every other
+ * shape, including nested elements, so this stays a narrow substitution rather
+ * than a general source-copy path.
+ *
+ * @param {{ wrapInfo?: object, tmp?: string }} context
+ * @returns {Promise}
+ */
+async function readJsxExpressionInner(context = {}) {
+ const wrapInfo = context.wrapInfo;
+ if (!wrapInfo) return null;
+ // Svelte previews bind through propContract downstream; leave them alone.
+ if (wrapInfo.previewMode === 'svelte-component') return null;
+ if (wrapInfo.commentSyntax?.open !== '{/*') return null;
+
+ const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp);
+ const inner = extractInnerSourceMarkup(original);
+ if (inner == null) return null;
+ const trimmed = inner.trim();
+ if (!trimmed || trimmed.includes('<')) return null;
+ if (!/\{[^{}]+\}/.test(trimmed)) return null;
+ return trimmed;
+}
+
+/**
+ * Recover the picked element's source markup from the scaffold. Deferred
+ * writes carry it in `wrapperBlock`; otherwise the wrapper is already in the
+ * file and the same block can be read back from disk.
+ */
+async function readOriginalMarkupFromWrap(wrapInfo, tmp) {
+ let text = wrapInfo.wrapperBlock;
+ if (!text && tmp && wrapInfo.file) {
+ try {
+ text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
+ } catch {
+ return null;
+ }
+ }
+ if (!text) return null;
+
+ const lines = String(text).split('\n');
+ const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"'));
+ if (startIdx === -1) return null;
+ const indent = (lines[startIdx].match(/^\s*/) || [''])[0];
+ const closer = `${indent}
`;
+ for (let i = startIdx + 1; i < lines.length; i++) {
+ if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n');
+ }
+ return null;
+}
+
+/** Content between an element's opening and closing tag, or null. */
+function extractInnerSourceMarkup(markup) {
+ const src = String(markup || '').trim();
+ if (!src) return null;
+ const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/);
+ if (!open || open[2].trim().endsWith('/')) return null;
+ const tag = open[1].toLowerCase();
+ const closeIdx = src.toLowerCase().lastIndexOf(`${tag}`);
+ if (closeIdx < open[0].length) return null;
+ if (!/^<\/[A-Za-z][\w:.-]*\s*>$/.test(src.slice(closeIdx))) return null;
+ return src.slice(open[0].length, closeIdx);
+}
+
/**
* Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml
* in HTML form; the orchestrator translates per the target file's syntax.
@@ -1402,6 +1677,15 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
+ // Contract-v2 path: keep the scaffolded stub (control flow + prop
+ // references) and swap only its \n`;
+ const compiled = compile(component, { generate: 'client' });
+ assert.ok(compiled.js.code.length > 0);
+ const fatal = (compiled.warnings || []).filter((w) => /error/i.test(w.code || ''));
+ assert.deepEqual(fatal, []);
+ });
+
+ it('collectRootIdentifiers skips member properties and object keys', () => {
+ const ast = parse(`
{fmt(user.name, { width: cols })}
`, { modern: true });
+ const tag = ast.fragment.nodes[0].fragment.nodes[0];
+ const roots = collectRootIdentifiers(tag.expression);
+ assert.deepEqual([...roots].sort(), ['cols', 'fmt', 'user']);
+ });
+
+ it('derivePropName picks stable tails', () => {
+ assert.equal(derivePropName('stages'), 'stages');
+ assert.equal(derivePropName('data.stages'), 'stages');
+ assert.equal(derivePropName('rows[0].label'), 'label');
+ assert.equal(derivePropName('a + b'), 'value');
+ });
+});
+
+describe('keyed each blocks', () => {
+ it('records a keyField for member keys and keeps the key in the scaffold', () => {
+ const src = `
{#each expenses as expense, i (expense.id)}
{expense.name}
{/each}
`;
+ const res = analyzeSvelteMarkup(src, parse);
+ assert.equal(res.ok, true, res.reason);
+ const collection = res.contract.find((c) => c.kind === 'collection');
+ assert.equal(collection.item.keyField, 'id');
+ assert.match(res.markupWithProps, /\(expense\.id\)/);
+ const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
+ assert.equal(restored.markup, src);
+ });
+
+ it('needs no keyField when the key is the item or the index', () => {
+ for (const src of [
+ `
`, parse);
+ assert.equal(res.ok, false);
+ assert.match(res.reason, /mixing loop and outer identifiers/);
+ });
+});
+
+describe('review regressions: each-key restore scoping', () => {
+ it('leaves a key that reads the loop binding alone when a prop shares its name', () => {
+ // The corruption shape: prop `name` maps back to `user.name`, and the
+ // loop context is ALSO called `name`. The key evaluates per item, so its
+ // `name` is the loop binding, never the prop; restoring it used to write
+ // `(user.name.id)` into the route.
+ const contract = [{ prop: 'name', expr: 'user.name', kind: 'text' }];
+ const markup = `
block. Its items use .card, and so does
+ // the intro div OUTSIDE the pick.
+ const lines = SHARED_SOURCE.split('\n');
+ const startLine = lines.findIndex((l) => l.includes('class="list"')) + 1;
+ const endLine = lines.findIndex((l) => l.trim() === '