diff --git a/skill/scripts/live/svelte-component.mjs b/skill/scripts/live/svelte-component.mjs
index 350e66b95..4993453a7 100644
--- a/skill/scripts/live/svelte-component.mjs
+++ b/skill/scripts/live/svelte-component.mjs
@@ -207,16 +207,18 @@ export function scaffoldSvelteComponentSession({
fs.mkdirSync(dir, { recursive: true });
const contract = analysis.contract;
- const seededCss = extractMatchingSourceCss(
+ 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).
- const seededSelectors = [...collectAllSelectors(seededCss)];
+ // ".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,
@@ -266,15 +268,26 @@ 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(/`;
+ const { css, supersedable } = extractMatchingSourceCss(route, '');
+ assert.match(css, /\.btn \{/);
+ assert.match(css, /\.stage \{/);
+ assert.doesNotMatch(css, /btn-primary/, '.btn must not seed .btn-primary');
+ assert.doesNotMatch(css, /\.stages/, '.stage must not seed .stages');
+ assert.deepEqual([...supersedable].sort(), ['.btn', '.stage']);
+ });
+
+ it('tag rules seed the preview but are never supersedable', () => {
+ const route = ``;
+ const { css, supersedable } = extractMatchingSourceCss(route, '
Title
');
+ assert.match(css, /h1 \{ font-size/, 'bare tag rules that style the pick are seeded');
+ assert.match(css, /h1\.hero/, 'class rules still seed');
+ assert.doesNotMatch(css, /^p \{/m, 'unrelated tags are not seeded');
+ assert.doesNotMatch(css, /\.sidebar/);
+ assert.deepEqual([...supersedable], ['h1.hero'], 'only class-matched selectors may be removed on accept');
+ });
});
describe('review regressions: preview-truth supersession (the Pitch mangle)', () => {
@@ -421,3 +453,88 @@ describe('review regressions: publish-time compile gate', () => {
}
});
});
+
+describe('review regressions: shared-class supersession guard', () => {
+ const SHARED_SOURCE = `
+
+
+
block. Its items use .card, and so does
+ // the intro div OUTSIDE the pick.
+ const lines = SHARED_SOURCE.split('\n');
+ const startLine = lines.findIndex((l) => l.includes('class="list"')) + 1;
+ const endLine = lines.findIndex((l) => l.trim() === '