mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
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:
co-authored by
Claude Code
parent
031e170d3e
commit
5b6b331785
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user