From 5b6b3317856502f32f698f03ebddfbc5bbf18ef1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 27 Jul 2026 18:45:07 -0700 Subject: [PATCH] fix: preview-truth CSS supersession + cascade ordering on Svelte accept Field failure from a real Codex session: accepting a variant into Pitch.svelte appended 23 selectors and removed none, so the source's old .decisions grid rules re-attached through the kept root class and forced the accepted board into a stale three-column layout; some appended base rules also landed after the source's media block, weakening the mobile cascade. Two mechanical fixes: - Preview truth: the scaffolder records the seeded selectors (the source rules that styled the replaced selection, which the isolated preview never applied). On accept, any seeded selector the variant does not re-declare is removed; the selector-loss postcondition treats those removals like compiler prunes. A regression test reproduces the exact Pitch shape end to end. - Cascade order: reconciliation inserts new base rules BEFORE existing top-level media blocks instead of appending after them. Init-latency reductions from the same transcript: - live.mjs inlines the resolved surface brief (removes three surface-brief.mjs round-trips including a --help miss before first poll). - The wrap/scaffold payload carries componentStubMarkup, and live.md instructs editing stubs in place (the session read the manifest + stub back and then deleted/recreated the files). - live.md notes that a busy default port usually means the dev server is already running (the session spawned a duplicate). This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code --- skill/reference/live.md | 4 +- skill/scripts/live-wrap.mjs | 1 + skill/scripts/live.mjs | 19 ++- skill/scripts/live/accept-css.mjs | 11 +- skill/scripts/live/svelte-component.mjs | 85 +++++++++++++- tests/live-svelte-component-accept.test.mjs | 123 ++++++++++++++++++++ 6 files changed, 233 insertions(+), 10 deletions(-) diff --git a/skill/reference/live.md b/skill/reference/live.md index 59886f274..55695ff4a 100644 --- a/skill/reference/live.md +++ b/skill/reference/live.md @@ -2,7 +2,7 @@ Interactive live variant mode: select elements in the browser, pick a design act ## Prerequisites -A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser. +A running dev server with hot module replacement (Vite, Next.js, Bun, etc.), OR a static HTML file open in the browser. If starting the dev server fails because its default port is busy, the app is very likely ALREADY running (a previous session's server); probe the default URL before spawning a second server on another port. Codex: run live helper commands, the app dev server, and any dependency-installing setup with `sandbox_permissions: "require_escalated"` from the start; live mode depends on localhost and package-manager network access that the sandbox blocks. @@ -165,7 +165,7 @@ If `--text` matches multiple candidates equally well, wrap exits with `{ error: Output on success: `{ file, insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring }`. On source-preview targets it also returns `sourceWritten: false`, `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (write it yourself per the `event.scaffold` note above). When you run this command directly (no preflight scaffold), it writes the wrapper into source itself, so there is no `wrapperBlock` and you splice variants at `insertLine`. -For Svelte/SvelteKit targets, `live-wrap.mjs` returns `previewMode: "svelte-component"` with `file` pointing at a temporary `node_modules/.impeccable-live//manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. The scaffold is AST-based: control-flow blocks (`{#each}`, `{#if}`) survive intact, a free each-collection crosses the contract as ONE structured prop (kind `collection`), and expressions bound by the loop stay verbatim in the stub. Write each variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`, keeping the stub's control flow and `propContract` prop names; never flatten a loop into literal items. Put variant CSS in each component's ``; + 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)); diff --git a/tests/live-svelte-component-accept.test.mjs b/tests/live-svelte-component-accept.test.mjs index 88ae9d9dc..dbe67ec56 100644 --- a/tests/live-svelte-component-accept.test.mjs +++ b/tests/live-svelte-component-accept.test.mjs @@ -6,6 +6,7 @@ import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { extractMatchingSourceCss, + removeSelectorsFromSvelteSource, findSvelteComponentManifest, inlineSvelteComponentAccept, mergeCssIntoSvelteSource, @@ -230,3 +231,125 @@ describe('svelte component scaffold + accept pipeline', () => { assert.doesNotMatch(css, /\.footer/); }); }); + +describe('review regressions: preview-truth supersession (the Pitch mangle)', () => { + const PITCH_SOURCE = ` + +
+
+ {#each verdicts as verdict} +
+

{verdict.label}

+

{verdict.detail}

+
+ {/each} +
+
+ + +`; + + it('removes seeded rules the variant did not re-declare and orders new base rules before media blocks', () => { + const tmp2 = realpathSync(mkdtempSync(join(tmpdir(), 'impeccable-pitch-mangle-'))); + try { + mkdirSync(join(tmp2, 'node_modules'), { recursive: true }); + try { + symlinkSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), 'dir'); + } catch { + cpSync(join(REPO_NODE_MODULES, 'svelte'), join(tmp2, 'node_modules', 'svelte'), { recursive: true }); + } + write(tmp2, 'package.json', JSON.stringify({ name: 'app' })); + write(tmp2, 'src/lib/Pitch.svelte', PITCH_SOURCE); + + // Picked element: the .decisions block (lines 10-17, 1-indexed). + const lines = PITCH_SOURCE.split('\n'); + const startLine = lines.findIndex((l) => l.includes('class="decisions"')) + 1; + const endLine = lines.findIndex((l, i) => i >= startLine && l.trim() === '' && lines[i + 1]?.includes('')) + 1; + const originalLines = lines.slice(startLine - 1, endLine); + + const session = scaffoldSvelteComponentSession({ + id: 'pitchm1', + count: 1, + sourceFile: 'src/lib/Pitch.svelte', + sourceStartLine: startLine, + sourceEndLine: endLine, + originalLines, + cwd: tmp2, + }); + assert.equal(session.fallback, undefined, session.reason); + // Seeded selectors recorded for accept-time supersession. + assert.equal(session.manifest.seededSelectors.includes('.decisions'), true); + + // The agent's variant: a NEW class, no re-declaration of .decisions. + write(tmp2, join(session.componentDir, 'v1.svelte'), ` + +
+ {#each verdicts as verdict} +
+

{verdict.label}

+

{verdict.detail}

+
+ {/each} +
+ + +`); + const manifest = findSvelteComponentManifest('pitchm1', tmp2); + const result = inlineSvelteComponentAccept(manifest, 1, null, tmp2); + assert.equal(result.handled, true, result.error); + const out = readFileSync(join(tmp2, 'src/lib/Pitch.svelte'), 'utf-8'); + + // The superseded grid rules are GONE: they never applied in the + // preview the user approved, and the root keeps the old class. + assert.doesNotMatch(out, /grid-template-columns: repeat\(3, 1fr\)/); + assert.doesNotMatch(out, /\.decisions > \.cell/); + assert.equal(result.css.superseded.includes('.decisions'), true); + // The untouched sibling rule survives. + assert.match(out, /\.pitch \{ padding: 40px; \}/); + // Source media block survives for the surviving class... + assert.match(out, /\.pitch \{ padding: 16px; \}/); + // ...and no longer carries the superseded selector. + assert.doesNotMatch(out, /\.decisions \{ grid-template-columns: 1fr; \}/); + // New base rules sit BEFORE the source's @media block (cascade order). + const baseIdx = out.indexOf('.disposition-board {'); + const mediaIdx = out.indexOf('@media (max-width: 700px)'); + assert.equal(baseIdx > -1 && mediaIdx > -1 && baseIdx < mediaIdx, true, + `expected base rules before media, got base@${baseIdx} media@${mediaIdx}`); + assert.equal(result.verify.clean, true, JSON.stringify(result.verify.findings)); + } finally { + rmSync(tmp2, { recursive: true, force: true }); + } + }); + + it('keeps seeded rules the variant re-declares', () => { + const { text, removed } = removeSelectorsFromSvelteSource('
x
\n', new Set(['.b'])); + assert.match(text, /\.a \{ color: red; \}/); + assert.doesNotMatch(text, /color: blue/); + assert.deepEqual(removed, ['.b']); + }); +});