From 6c1aff7d1f62abddb5bdfc49ec6534b86531886e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 20:13:15 +0000 Subject: [PATCH] Sync generated provider output --- .../skills/impeccable/reference/live-setup.md | 102 ++ .agents/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .agents/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .claude/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .claude/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .cursor/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .cursor/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .gemini/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .gemini/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .github/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .github/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .grok/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .grok/skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .grok/skills/impeccable/scripts/live-wrap.mjs | 81 +- .grok/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .kiro/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .kiro/skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .kiro/skills/impeccable/scripts/live-wrap.mjs | 81 +- .kiro/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .opencode/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .opencode/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .pi/skills/impeccable/reference/live-setup.md | 102 ++ .pi/skills/impeccable/reference/live.md | 633 ++-------- .pi/skills/impeccable/scripts/live-accept.mjs | 2 + .pi/skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .pi/skills/impeccable/scripts/live-inject.mjs | 563 +++------ .pi/skills/impeccable/scripts/live-insert.mjs | 2 + .pi/skills/impeccable/scripts/live-poll.mjs | 19 +- .pi/skills/impeccable/scripts/live-resume.mjs | 49 +- .pi/skills/impeccable/scripts/live-server.mjs | 193 +++- .pi/skills/impeccable/scripts/live-status.mjs | 14 +- .pi/skills/impeccable/scripts/live-wrap.mjs | 81 +- .pi/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .pi/skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .qoder/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .qoder/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .rovodev/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .rovodev/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .trae-cn/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- .trae-cn/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .trae/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .trae/skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .trae/skills/impeccable/scripts/live-wrap.mjs | 81 +- .trae/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ .vibe/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .vibe/skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .vibe/skills/impeccable/scripts/live-wrap.mjs | 81 +- .vibe/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ .../skills/impeccable/reference/live-setup.md | 102 ++ plugin/skills/impeccable/reference/live.md | 633 ++-------- .../skills/impeccable/scripts/live-accept.mjs | 2 + .../skills/impeccable/scripts/live-browser.js | 1027 +++++++++++++++-- .../impeccable/scripts/live-complete.mjs | 34 +- .../skills/impeccable/scripts/live-inject.mjs | 563 +++------ .../skills/impeccable/scripts/live-insert.mjs | 2 + .../skills/impeccable/scripts/live-poll.mjs | 19 +- .../skills/impeccable/scripts/live-resume.mjs | 49 +- .../skills/impeccable/scripts/live-server.mjs | 193 +++- .../skills/impeccable/scripts/live-status.mjs | 14 +- .../skills/impeccable/scripts/live-wrap.mjs | 81 +- plugin/skills/impeccable/scripts/live.mjs | 105 +- .../impeccable/scripts/live/accept-css.mjs | 617 ++++++++++ .../impeccable/scripts/live/accept-verify.mjs | 60 + .../scripts/live/browser-script-parts.mjs | 7 +- .../scripts/live/event-validation.mjs | 61 +- .../scripts/live/frameworks/astro.mjs | 47 + .../scripts/live/frameworks/detect-utils.mjs | 73 ++ .../scripts/live/frameworks/index.mjs | 143 +++ .../scripts/live/frameworks/journal.mjs | 197 ++++ .../scripts/live/frameworks/nextjs.mjs | 49 + .../scripts/live/frameworks/nuxt.mjs | 161 +++ .../scripts/live/frameworks/script-src.mjs | 17 + .../scripts/live/frameworks/static-html.mjs | 26 + .../scripts/live/frameworks/sveltekit.mjs | 71 ++ .../scripts/live/frameworks/tag-strategy.mjs | 247 ++++ .../live/frameworks/tanstack-start.mjs | 70 ++ .../scripts/live/frameworks/vite-generic.mjs | 42 + .../impeccable/scripts/live/instructions.mjs | 142 +++ .../skills/impeccable/scripts/live/roots.mjs | 508 ++++++++ .../impeccable/scripts/live/session-store.mjs | 267 ++++- .../impeccable/scripts/live/svelte-ast.mjs | 961 +++++++++++++++ .../scripts/live/svelte-component.mjs | 660 +++++++++-- .../scripts/live/sveltekit-adapter.mjs | 62 +- .../scripts/live/tanstack-adapter.mjs | 2 +- .../impeccable/scripts/live/vocabulary.mjs | 135 +++ 555 files changed, 92925 insertions(+), 18810 deletions(-) create mode 100644 .agents/skills/impeccable/reference/live-setup.md create mode 100644 .agents/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .agents/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .agents/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .agents/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .agents/skills/impeccable/scripts/live/roots.mjs create mode 100644 .agents/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .claude/skills/impeccable/reference/live-setup.md create mode 100644 .claude/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .claude/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .claude/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .claude/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .claude/skills/impeccable/scripts/live/roots.mjs create mode 100644 .claude/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .cursor/skills/impeccable/reference/live-setup.md create mode 100644 .cursor/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/roots.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .gemini/skills/impeccable/reference/live-setup.md create mode 100644 .gemini/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/roots.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .github/skills/impeccable/reference/live-setup.md create mode 100644 .github/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .github/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .github/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .github/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .github/skills/impeccable/scripts/live/roots.mjs create mode 100644 .github/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .grok/skills/impeccable/reference/live-setup.md create mode 100644 .grok/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .grok/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .grok/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .grok/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .grok/skills/impeccable/scripts/live/roots.mjs create mode 100644 .grok/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .kiro/skills/impeccable/reference/live-setup.md create mode 100644 .kiro/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/roots.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .opencode/skills/impeccable/reference/live-setup.md create mode 100644 .opencode/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/roots.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .pi/skills/impeccable/reference/live-setup.md create mode 100644 .pi/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .pi/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .pi/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .pi/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .pi/skills/impeccable/scripts/live/roots.mjs create mode 100644 .pi/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .qoder/skills/impeccable/reference/live-setup.md create mode 100644 .qoder/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/roots.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .rovodev/skills/impeccable/reference/live-setup.md create mode 100644 .rovodev/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/roots.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .trae-cn/skills/impeccable/reference/live-setup.md create mode 100644 .trae-cn/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/roots.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .trae/skills/impeccable/reference/live-setup.md create mode 100644 .trae/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .trae/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .trae/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .trae/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .trae/skills/impeccable/scripts/live/roots.mjs create mode 100644 .trae/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 .vibe/skills/impeccable/reference/live-setup.md create mode 100644 .vibe/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/instructions.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/roots.mjs create mode 100644 .vibe/skills/impeccable/scripts/live/svelte-ast.mjs create mode 100644 plugin/skills/impeccable/reference/live-setup.md create mode 100644 plugin/skills/impeccable/scripts/live/accept-css.mjs create mode 100644 plugin/skills/impeccable/scripts/live/accept-verify.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/astro.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/detect-utils.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/index.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/journal.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/nextjs.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/nuxt.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/script-src.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/static-html.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/sveltekit.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/tag-strategy.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/tanstack-start.mjs create mode 100644 plugin/skills/impeccable/scripts/live/frameworks/vite-generic.mjs create mode 100644 plugin/skills/impeccable/scripts/live/instructions.mjs create mode 100644 plugin/skills/impeccable/scripts/live/roots.mjs create mode 100644 plugin/skills/impeccable/scripts/live/svelte-ast.mjs diff --git a/.agents/skills/impeccable/reference/live-setup.md b/.agents/skills/impeccable/reference/live-setup.md new file mode 100644 index 000000000..950d1346f --- /dev/null +++ b/.agents/skills/impeccable/reference/live-setup.md @@ -0,0 +1,102 @@ +One-time live-mode project setup. Loaded from [live.md](live.md) only when `live.mjs` reports `config_missing` / `config_invalid`, when `configDrift` needs handling, or when the config lacks `cspChecked`. Not part of the per-session hot path. + +## Write the config + +Create the file at the `path` the boot reported (default `.impeccable/live/config.json`): + +```json +{ + "files": ["", "", ...], + "exclude": ["", ...], + "insertBefore": "", + "commentSyntax": "html", + "cspChecked": true +} +``` + +`files` is the inject target: **the HTML files the browser actually loads**, not necessarily source (tracked vs generated does not matter here; wrap has its own generated-file guard). Entries are literal paths or globs. `exclude` (optional) skips files a `files` glob would otherwise include (email templates, demo fixtures). `cspChecked` records that the CSP step below has run; absent on first setup. + +**Hard-excluded paths (cannot be overridden):** `**/node_modules/**` and `**/.git/**`; injecting there would instrument third-party code. + +**Glob syntax:** `**` matches any number of segments (including zero), `*` matches within a segment, `?` matches one character. Paths are project-root-relative with forward slashes. + +| Framework | `files` | `insertBefore` | `commentSyntax` | +|-----------|---------|----------------|-----------------| +| SPA with single shell (Vite / React / Plain HTML) | `["index.html"]` | `` | `html` | +| Next.js (App Router) | `["app/layout.tsx"]` | `` | `jsx` | +| Next.js (Pages) | `["pages/_document.tsx"]` | `` | `jsx` | +| Nuxt | `["app.vue"]` | `` | `html` | +| Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | +| TanStack Router (SPA, Vite) | `["index.html"]` | `` | `html` | +| TanStack Start (SSR) | `["src/routes/__root.tsx"]` | `"]` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` glob over the served dir | `` | `html` | + +Pick an anchor that exists in every file (`` almost always works); `insertAfter` matches after a line instead. For multi-page sites prefer a glob so new pages are picked up automatically. For sites whose pages are rebuilt by a generator, the inject survives only until the next regeneration: re-run `live.mjs` after each build (accept is unaffected; it writes true source via the fallback flow). + +**Framework adapters (auto-detected at inject time).** Every inject records what it wrote in `.impeccable/live/inject-journal.json`; the next inject or remove heals artifacts a crash or wrong-directory stop left behind. SvelteKit, Nuxt, and TanStack Start server-render their document shell, so a raw `\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 `` naturally - // belong at the end, and the same literal can appear earlier in code blocks - // within rendered documentation pages. - if (config.insertBefore) { - const idx = content.lastIndexOf(config.insertBefore); - if (idx === -1) return content; - return content.slice(0, idx) + block + content.slice(idx); - } - // insertAfter: match the FIRST occurrence — typical anchors like `` or - // `` open near the top of the document. - const idx = content.indexOf(config.insertAfter); - if (idx === -1) return content; - const after = idx + config.insertAfter.length; - // Preserve 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. - * ``), 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/.agents/skills/impeccable/scripts/live-insert.mjs b/.agents/skills/impeccable/scripts/live-insert.mjs index b4c17cca1..8d5829ea4 100644 --- a/.agents/skills/impeccable/scripts/live-insert.mjs +++ b/.agents/skills/impeccable/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/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index 19a8b0f9b..3b2f08c9f 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/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/.agents/skills/impeccable/scripts/live-resume.mjs b/.agents/skills/impeccable/scripts/live-resume.mjs index 74284d48a..b9459a8d4 100644 --- a/.agents/skills/impeccable/scripts/live-resume.mjs +++ b/.agents/skills/impeccable/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/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index 113ad01b3..bfad7a245 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .grok/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .grok/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .grok/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .qoder/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .qoder/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .qoder/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .vibe/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .vibe/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .vibe/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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 -
- {/* variant 1 */} -
{/* variant 2 */}
``` -The wrap script already gives you a single-rooted JSX wrapper: a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. +The wrap script provides a single-rooted JSX wrapper with the marker comments inside; drop the block at the marker and the source stays valid TSX. -### 7. Parameters (composition-sized, 0–4 per variant) +### 7. Parameters (composition-sized, 0-4 per variant) -Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. +Each variant can expose **coarse** knobs; the browser docks one control per parameter with zero regeneration cost (knobs drive a CSS variable or data attribute your scoped CSS is authored against). Wire an axis as soon as the user could plausibly mutter "a bit tighter" or "a touch more accent" without wanting a regeneration; micro-margins and one-off nudges are not parameters. Freeform bias: you chose the axes, so expose them; a hero with 0 params is almost always a mistake, and 1 is underweight unless the design is a genuine fixed point. -**What “optional” does not mean.** Parameters are not nice-to-have decoration on large work. The word meant “omit controls that are redundant or cosmetic,” not “default to zero because three variants were enough work.” +Budget scales with the element's VISUAL weight (count visual children, not DOM depth): -**When to add.** As soon as the variant’s scoped CSS has a meaningful continuous or stepped axis: density, color amount, type scale, motion intensity, column weight, and so on. If you can imagine the user muttering “a bit tighter” or “a touch more accent” **without** wanting a full regeneration, wire that axis. **Not** micro-margins or one-off nudges; those are not parameters. +- **Leaf / tiny** (button, icon, bare heading): **0 params.** +- **Small composition** (simple card, labeled input, ≤ ~5 visual children): **0-1**. +- **Medium composition** (section, nav cluster, 6-15 children): **target 2**; 1 if simple. +- **Large composition** (hero, full region, 16+ children or sub-sections): **target 2-3, up to 4** when independent axes are all authored in CSS. -**Freeform (`action` is `impeccable`) bias.** You did not load a sub-command reference, so you must **choose** signature axes yourself. Match the budget table: for a hero or large composition, that means **2–3 axes per variant**, not 1. Prefer knobs that sit on the dimensions where your three variants actually differ (if density varies, expose it as a `steps` knob; if color commitment varies, expose it as a `range`). A hero that ships with **0** params is almost always a mistake, not a judgment call. A hero with exactly **1** param is underweight unless the design is genuinely a fixed-point comparison. Start from the budget table, not from zero. +**Hard cap: four** per variant. For named sub-commands, the action reference's MUST params are non-negotiable when expressible; respect the cap, no duplicate knobs. -**Budget scales with the element's visual weight, not token budget.** Knobs need real estate to read as tunable; three sliders on a single control are noise. - -- **Leaf / tiny**: a single button, icon, input, bare heading, solitary paragraph: **0 params.** -- **Small composition**: labeled input, simple card, short callout (≤ ~5 visual children): **0–1** params when one dominant axis is obvious; otherwise **0.** -- **Medium composition**: section component, nav cluster, dense card, short feature block (6–15 visual children): **target 2**; **1** is acceptable if the block is simple; **0** only when variants are truly fixed points. -- **Large composition**: hero section, full page region, spread layout, strong internal structure (16+ visual children or multiple sub-sections): **target 2–3**; **up to 4** when several independent axes (e.g. structure `steps` + `density` + one accent) are all authored in scoped CSS. - -**When in doubt, ask whether a dial exists before defaulting to zero.** The user can always request more variants, but the point of live mode is instant tuning without another Go. Crowding the panel is bad; **under-shipping** knobs on a dense composition is the more common failure for freeform. Count by **visual** children, not DOM depth; a shallow-but-wide hero is still large. - -**Hard cap per variant**: at most **four** parameters so the panel stays legible; rare fifth only if the reference explicitly allows it. - -**How to declare.** Put a JSON manifest on the variant wrapper (HTML/JSX path). **On the `svelte-component` path, do not use this attribute.** Declare params in `componentDir/params.json` keyed by variant number instead (see the component-preview paragraphs in the wrap section). The param schema below is identical for every path. +**Declare** on the HTML/JSX path as a wrapper attribute (component-preview paths use `componentDir/params.json` instead, same schema, keyed by variant number; see the wrap section): ```html
- ...variant content... -
``` -**Three kinds:** +Three kinds: `range` (slider; drives `--p-`; author `var(--p-color-amount, 0.5)`; fields min/max/step/default/label), `steps` (segmented radio; drives `data-p-`; author `:scope[data-p-density="airy"] .grid { ... }`; fields options/default/label), `toggle` (drives both `--p-: 0|1` and attribute presence; fields default/label). Reset on variant switch is a known limitation: each variant starts at its declared defaults. -- `range`: smooth slider. Drives a CSS custom property `--p-` on the variant wrapper. Author CSS with `var(--p-color-amount, 0.5)`. Fields: `min`, `max`, `step`, `default` (number), `label`. -- `steps`: segmented radio. Drives a data attribute `data-p-` on the variant wrapper. Author CSS with `:scope[data-p-density="airy"] .grid { ... }`. Fields: `options` (array of `{value, label}`), `default` (string), `label`. -- `toggle`: on/off switch. Drives BOTH a CSS var (`--p-: 0|1`) and a data attribute (present when on, absent when off). Use whichever is more convenient. Fields: `default` (boolean), `label`. - -**Signature params per action.** For named sub-commands, read that action’s `reference/.md` for one or two **MUST** params (e.g. `layout` → `density`). Those are non-negotiable when the design can express them. **Freeform has no file-level MUST**; the **Freeform (`impeccable`) bias** in this section is the stand-in. If the user’s action is both stylized and sub-command (e.g. `colorize`), the sub-command’s MUST list takes precedence for its axes; still respect the **Hard cap** and add no redundant duplicate knobs. - -**Reset on variant switch.** User dials density on v1, flips to v2, v2 starts at v2's declared defaults. Known limitation; preservation across variants may land later. - -**On accept**, the browser sends the user's current values in the accept event. `live-accept.mjs` writes them as a sibling comment: - -```html - -``` - -The carbonize cleanup step (see below) reads that comment and bakes the chosen values into the final CSS. For `steps`/`toggle` attribute selectors: keep only the branch matching the chosen value, drop the others, collapse `:scope[data-p-density="packed"] .grid` to a semantic class rule. For `range` vars: either substitute the literal or keep the var with the chosen value as its new default. +**On accept**, the browser sends current values and `live-accept.mjs` writes them as a sibling comment: ``. Carbonize cleanup bakes them: keep only the matching `steps`/`toggle` branch, drop the others, collapse `:scope[data-p-…]` to semantic rules; substitute `range` literals or update the var's default. ### 8. Signal done @@ -407,127 +243,56 @@ The carbonize cleanup step (see below) reads that comment and bakes the chosen v node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH ``` -`RELATIVE_PATH` is relative to project root (`public/index.html`, `src/App.tsx`, etc.); the browser fetches source directly if the dev server lacks HMR. - -Then run `live-poll.mjs` again immediately. +`RELATIVE_PATH` is relative to project root; the browser fetches source directly if the dev server lacks HMR. Then poll again immediately. ### Aborting an in-flight session -If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: - -```bash -node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" -``` - -Don't run `live-accept --discard` for this; that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. +If wrap or generation fails after the browser flipped to GENERATING, tell the **browser** so its bar resets: `node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason"`. Never use `live-accept --discard` for this (pure file mutator, browser never sees it, bar sticks on dots); `--discard` is only source-side cleanup for a discard the browser itself initiated. ## Handle fallback -When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. +When wrap returns `fallback: "agent-driven"`, you pick the source file yourself; the goal is unchanged: three preview variants now, and the accepted one persisted where the next build cannot wipe it. -The goal is the same: give the user three variants to choose from AND persist the accepted one in a place the next build won't wipe. The difference is that you have to pick the right source file yourself. - -### Step 1: Identify where the element actually lives - -Use the error payload: - -- `element_not_in_source` with `generatedMatch: "public/docs/foo.html"`: the served HTML is generated. Find the generator (grep for writers of that path, e.g. `scripts/build-sub-pages.js`, an Astro/Next template) and locate the template or partial that emits this element. -- `element_not_found`: the element is runtime-injected. Look for the component that renders it (React/Vue/Svelte), the JS that assembles it, or the data source that feeds it. -- `file_is_generated` with `file: "..."`: user pointed at a generated file explicitly. Same resolution as `element_not_in_source`. - -Read the candidate source until you're confident where a change to the element would belong. If the change is purely visual, that source might be a shared stylesheet, not the template. - -### Step 2: Show three variants in the DOM for preview - -The browser bar is waiting for variants. Even without a wrapper in source, you still need to show something: - -1. Manually write the wrapper scaffold into the **served** file (the one the browser actually loaded). Use the same structure `live-wrap.mjs` produces; `
`. -2. Insert your three variant divs inside it, same shape as the deterministic path. -3. Signal done with `--reply EVENT_ID done --file `. The browser's no-HMR fallback will fetch and inject. - -This served-file edit is **temporary**: next regen wipes it, and that's fine. The real work happens on accept. - -### Step 3: On accept, write to true source - -When the accept event arrives (`_acceptResult.handled` will usually be `false` here because accept also refuses to persist into generated files; see Handle accept for the carbonize branch), extract the accepted variant's content and write it into the source you identified in Step 1: - -- Structural change → edit the template / component source. -- Visual-only change → add or update rules in the appropriate stylesheet; remove the inline `\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(/]*>([\s\S]*?)<\/style\s*>/i); + if (!styleMatch) return empty; + const classNames = new Set(); + const classRe = /class\s*=\s*(["'])(.*?)\1/g; + let m; + while ((m = classRe.exec(originalMarkup))) { + for (const cls of m[2].split(/\s+/)) if (cls && !cls.includes('{')) classNames.add(cls); + } + const tagRe = /<([a-z][a-z0-9-]*)/gi; + const tags = new Set(); + while ((m = tagRe.exec(originalMarkup))) tags.add(m[1].toLowerCase()); + if (classNames.size === 0 && tags.size === 0) return empty; + + // Token-boundary matching, never substring: `.btn` must not match + // `.btn-primary`, and `.stage` must not match `.stages`. A substring hit + // seeds a rule that never styled the pick, and a falsely seeded selector + // becomes an accept-time DELETION of a hand-written rule. + const classRes = [...classNames].map((cls) => new RegExp('\\.' + escapeSelectorToken(cls) + '(?![A-Za-z0-9_-])')); + const tagRes = [...tags].map((tag) => new RegExp('(^|[\\s>+~,(])' + escapeSelectorToken(tag) + '(?![A-Za-z0-9_-])', 'i')); + const classMatches = (selector) => classRes.some((re) => re.test(selector)); + const tagMatches = (selector) => tagRes.some((re) => re.test(selector)); + + const supersedable = new Set(); + const ruleMatches = (prelude) => { + let matched = false; + for (const selector of splitSelectorList(prelude)) { + if (classMatches(selector)) { + matched = true; + supersedable.add(normalizeSelector(selector)); + } else if (tagMatches(selector)) { + matched = true; + } + } + return matched; + }; + + const pick = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule' && ruleMatches(node.prelude)) kept.push(node); + else if (node.type === 'at' && node.children) { + const children = pick(node.children); + if (children.length) kept.push({ ...node, children }); + } + } + return kept; + }; + return { css: serializeNodes(pick(parseStylesheet(styleMatch[1]))), supersedable }; +} + +function buildVariantStubV2(variantNum, markupWithProps, contract, seededCss) { + const propsComment = contract.length > 0 + ? `\n\n` + : ''; + // The guard comments must never contain the literal "\n /* Variant ${variantNum}: seeded from the route's current rules; restyle or delete freely.\n ALL rules go inside THIS block. Svelte allows exactly one top-level style\n element per component; appending a second one is a compile error. */\n${seededCss.split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n\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(/]*>[\s\S]*?<\/style\s*>/gi, ''); + const outsideClasses = new Set(); + { + const attrRe = /class\s*=\s*(["'])(.*?)\1/g; + let cm; + while ((cm = attrRe.exec(outsideMarkup))) { + for (const cls of cm[2].split(/\s+/)) if (cls && !cls.includes('{')) outsideClasses.add(cls); + } + const directiveRe = /class:([A-Za-z0-9_-]+)/g; + while ((cm = directiveRe.exec(outsideMarkup))) outsideClasses.add(cm[1]); + } + const usedOutsideReplacedRegion = (selector) => { + const classTokenRe = /\.([A-Za-z0-9_-]+)/g; + let tm; + while ((tm = classTokenRe.exec(selector))) { + if (outsideClasses.has(tm[1])) return true; + } + return false; + }; + const incomingSelectors = collectAllSelectors(bakedCss); + const superseded = (manifest.seededSelectors || []) + .map((selector) => normalizeSelector(selector)) + .filter((selector) => selector && !incomingSelectors.has(selector) && !usedOutsideReplacedRegion(selector)); + if (superseded.length > 0) { + const scrubbed = removeSelectorsFromSvelteSource(finalText, new Set(superseded)); + finalText = scrubbed.text; + cssStats.superseded = scrubbed.removed; + } + + if (compiler) { + const pruned = pruneUnusedSelectors(finalText, compiler.compile, { skipSelectors: preUnused }); + finalText = pruned.source; + cssStats.pruned = pruned.removed; + } + + // Postcondition: no selector from the user's pre-accept CSS may vanish + // unless the compiler-driven prune or the preview-truth supersession + // deliberately removed it. This turns any parser or reconciler defect into + // a loud refusal instead of silent damage to a hand-written style block. + const lostSelectors = findLostSelectors(sourceContent, finalText, [ + ...cssStats.pruned, + ...cssStats.superseded, + ]); + if (lostSelectors.length > 0) { + return { + handled: false, + error: 'CSS reconciliation would lose selectors from the existing style block: ' + + lostSelectors.join(', ') + + '. Source not modified; accept the variant manually.', + mode: 'error', + ...resultBase, + }; } try { - fs.writeFileSync(sourceFile, newLines.join('\n'), 'utf-8'); + fs.writeFileSync(sourceFile, finalText, 'utf-8'); } catch (err) { return { handled: false, error: 'Failed to write Svelte source: ' + err.message, ...resultBase }; } removeSvelteComponentSession(manifest.id, cwd); + const verify = verifyAcceptedSource(finalText); return { handled: true, + css: cssStats, + verify, ...resultBase, }; } +/** Re-indent a block onto `indent` while preserving its internal structure. */ +export function reindentPreservingStructure(lines, indent) { + const nonEmpty = lines.filter((line) => line.trim() !== ''); + if (nonEmpty.length === 0) return lines.map(() => ''); + const minIndent = Math.min(...nonEmpty.map((line) => (line.match(/^\s*/) || [''])[0].length)); + return lines.map((line) => { + if (line.trim() === '') return ''; + const current = (line.match(/^\s*/) || [''])[0].length; + return indent + line.slice(Math.min(minIndent, current)); + }); +} + +function styleBlockText(sourceText) { + const match = String(sourceText || '').match(/]*>([\s\S]*?)<\/style\s*>/i); + return match ? match[1] : ''; +} + +/** + * Remove every rule whose (normalized) selector list is fully contained in + * `selectors` from the component's style block, at any at-rule nesting depth. + * Rules that mix doomed and surviving selectors keep the survivors. + */ +export function removeSelectorsFromSvelteSource(sourceText, selectors) { + const text = String(sourceText || ''); + const styleRe = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + if (!lastMatch) return { text, removed: [] }; + + const removed = []; + const transform = (nodes) => { + const kept = []; + for (const node of nodes) { + if (node.type === 'rule') { + const survivors = []; + for (const selector of splitSelectorList(node.prelude)) { + if (selectors.has(normalizeSelector(selector))) removed.push(normalizeSelector(selector)); + else survivors.push(selector); + } + if (survivors.length > 0) kept.push({ ...node, prelude: survivors.join(', ') }); + } else if (node.type === 'at' && node.children) { + const children = transform(node.children); + if (children.length > 0) kept.push({ ...node, children }); + } else { + kept.push(node); + } + } + return kept; + }; + + const nodes = transform(parseStylesheet(lastMatch[1])); + if (removed.length === 0) return { text, removed }; + const openTag = lastMatch[0].slice(0, lastMatch[0].indexOf('>') + 1); + const rebuilt = `${openTag}\n${serializeNodes(nodes).split('\n').map((l) => (l.trim() ? ' ' + l : '')).join('\n')}\n`; + 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 = /]*>([\s\S]*?)<\/style\s*>/gi; + let lastMatch = null; + let m; + while ((m = styleRe.exec(text))) lastMatch = m; + + if (!lastMatch) { + const { css, replaced, appended } = reconcileCss('', incomingCss); + return { + text: `${text.replace(/\s*$/, '')}\n\n\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