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 <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-07-27 18:45:07 -07:00
co-authored by Claude Code
parent 031e170d3e
commit 5b6b331785
6 changed files with 233 additions and 10 deletions
+1
View File
@@ -436,6 +436,7 @@ The agent should insert variant HTML at insertLine.`);
replaceEndLine: deferredWrapper ? deferredWrapper.replaceEndLine : undefined,
componentDir: componentSession?.componentDir,
propContract: componentSession?.propContract,
componentStubMarkup: componentSession?.stubMarkup,
sourceStartLine: componentPreviewActive ? startLine + 1 : undefined,
sourceEndLine: componentPreviewActive ? endLine + 1 : undefined,
startLine: outputStartLine, // 1-indexed for the agent
+18 -1
View File
@@ -24,6 +24,7 @@ import { fileURLToPath } from 'node:url';
import { resolveTargetSelection } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
import { resolveLiveTarget } from './live-target.mjs';
import { resolveRoots, writeRootsManifest } from './live/roots.mjs';
@@ -161,7 +162,20 @@ The agent should then:
const resolvedFiles = resolveFiles(activeCwd, checkResult.config);
const drift = scanForDrift(activeCwd, resolvedFiles, checkResult.config);
// 5. Emit everything the agent needs
// 5. Emit everything the agent needs. The surface brief rides along so the
// agent does not spend three more tool calls (and a --help miss) on
// surface-brief.mjs before the first poll.
let surfaceBrief = null;
let surfaceBriefPath = null;
try {
const resolvedBrief = resolveSurfaceBrief(roots.appRoot, liveTarget.absoluteTargetPath || null);
if (resolvedBrief?.brief) {
surfaceBrief = resolvedBrief.brief.text ?? safeRead(resolvedBrief.brief.path);
surfaceBriefPath = resolvedBrief.brief.path
? path.relative(liveTarget.originalCwd, resolvedBrief.brief.path)
: null;
}
} catch { /* briefs are optional context */ }
console.log(JSON.stringify({
ok: true,
serverPort: serverInfo.port,
@@ -179,6 +193,9 @@ The agent should then:
hasDesign: !!design,
design,
designPath: relOrNull(liveTarget.originalCwd, roots.designPath),
hasSurfaceBrief: !!surfaceBrief,
surfaceBrief,
surfaceBriefPath,
}, null, 2));
}
+9 -2
View File
@@ -247,8 +247,15 @@ export function reconcileCss(existingCss, variantCss) {
}
touched.add(key);
} else {
existingNodes.push({ ...node });
index.set(key, existingNodes[existingNodes.length - 1]);
// New base rules go BEFORE the existing top-level media blocks:
// appended after them, an equal-specificity base rule wins the
// cascade over the stylesheet's earlier responsive overrides and
// silently weakens the mobile styles for any still-shared class.
const appendedNode = { ...node };
const firstAt = existingNodes.findIndex((n) => n.type === 'at' && n.children);
if (firstAt === -1) existingNodes.push(appendedNode);
else existingNodes.splice(firstAt, 0, appendedNode);
index.set(key, appendedNode);
touched.add(key);
appended++;
}
+80 -5
View File
@@ -211,6 +211,12 @@ export function scaffoldSvelteComponentSession({
safeReadSource(path.resolve(cwd, sourceFile)),
originalMarkup,
);
// 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).
const seededSelectors = [...collectAllSelectors(seededCss)];
const manifest = {
id,
@@ -222,6 +228,7 @@ export function scaffoldSvelteComponentSession({
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
@@ -247,6 +254,11 @@ 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,
};
}
@@ -682,7 +694,7 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
variantCss = sanitizeAcceptedSvelteCss(cssLines, variantNum, paramValues, rootTag).join('\n');
}
const bakedCss = bakeParamValues(variantCss, declaredParams, paramValues || {});
const cssStats = { replaced: 0, appended: 0, pruned: [] };
const cssStats = { replaced: 0, appended: 0, pruned: [], superseded: [] };
if (bakedCss.trim()) {
const merged = mergeCssIntoSvelteSource(newLines.join('\n'), bakedCss);
newLines = merged.text.split('\n');
@@ -691,6 +703,22 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
}
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.
const incomingSelectors = collectAllSelectors(bakedCss);
const superseded = (manifest.seededSelectors || [])
.map((selector) => normalizeSelector(selector))
.filter((selector) => selector && !incomingSelectors.has(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;
@@ -698,10 +726,13 @@ export function inlineSvelteComponentAccept(manifest, variantNum, paramValues =
}
// Postcondition: no selector from the user's pre-accept CSS may vanish
// unless the compiler-driven prune 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);
// 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,
@@ -746,6 +777,50 @@ function styleBlockText(sourceText) {
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 = /<style\b[^>]*>([\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</style>`;
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));