diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.agents/skills/impeccable/scripts/live-accept.mjs
+++ b/.agents/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.agents/skills/impeccable/scripts/live-browser.js
+++ b/.agents/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.agents/skills/impeccable/scripts/live-inject.mjs
+++ b/.agents/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.claude/skills/impeccable/scripts/live-accept.mjs
+++ b/.claude/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.claude/skills/impeccable/scripts/live-browser.js
+++ b/.claude/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.claude/skills/impeccable/scripts/live-inject.mjs
+++ b/.claude/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.cursor/skills/impeccable/scripts/live-accept.mjs
+++ b/.cursor/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.cursor/skills/impeccable/scripts/live-browser.js
+++ b/.cursor/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.cursor/skills/impeccable/scripts/live-inject.mjs
+++ b/.cursor/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.gemini/skills/impeccable/scripts/live-accept.mjs
+++ b/.gemini/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.gemini/skills/impeccable/scripts/live-browser.js
+++ b/.gemini/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.gemini/skills/impeccable/scripts/live-inject.mjs
+++ b/.gemini/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.github/skills/impeccable/scripts/live-accept.mjs
+++ b/.github/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.github/skills/impeccable/scripts/live-browser.js
+++ b/.github/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.github/skills/impeccable/scripts/live-inject.mjs
+++ b/.github/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.kiro/skills/impeccable/scripts/live-accept.mjs
+++ b/.kiro/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.kiro/skills/impeccable/scripts/live-browser.js
+++ b/.kiro/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.kiro/skills/impeccable/scripts/live-inject.mjs
+++ b/.kiro/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.opencode/skills/impeccable/scripts/live-accept.mjs
+++ b/.opencode/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.opencode/skills/impeccable/scripts/live-browser.js
+++ b/.opencode/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.opencode/skills/impeccable/scripts/live-inject.mjs
+++ b/.opencode/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.pi/skills/impeccable/scripts/live-accept.mjs
+++ b/.pi/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.pi/skills/impeccable/scripts/live-browser.js
+++ b/.pi/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.pi/skills/impeccable/scripts/live-inject.mjs
+++ b/.pi/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.rovodev/skills/impeccable/scripts/live-accept.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.rovodev/skills/impeccable/scripts/live-browser.js
+++ b/.rovodev/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.rovodev/skills/impeccable/scripts/live-inject.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.trae-cn/skills/impeccable/scripts/live-browser.js
+++ b/.trae-cn/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/.trae/skills/impeccable/scripts/live-accept.mjs
+++ b/.trae/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/.trae/skills/impeccable/scripts/live-browser.js
+++ b/.trae/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/.trae/skills/impeccable/scripts/live-inject.mjs
+++ b/.trae/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/source/skills/impeccable/scripts/live-accept.mjs b/source/skills/impeccable/scripts/live-accept.mjs
index 73de49e2d..26bd285e3 100644
--- a/source/skills/impeccable/scripts/live-accept.mjs
+++ b/source/skills/impeccable/scripts/live-accept.mjs
@@ -149,13 +149,16 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
const replacement = [];
if (cssContent) {
+ const isJsx = commentSyntax.open === '{/*';
replacement.push(indent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
- replacement.push(indent + '');
+ replacement.push(indent + (isJsx ? '`}' : ''));
if (paramValues && Object.keys(paramValues).length > 0) {
// Preserve the user's knob positions for the carbonize-cleanup agent
// to bake into the final CSS when it collapses scoped rules.
@@ -169,8 +172,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) {
// in a data-impeccable-variant="N" div with `display: contents` (so layout
// isn't affected). The carbonize agent strips this attribute + wrapper when
// it moves the CSS to a proper stylesheet.
+ //
+ // Style attribute syntax has to follow the host file's flavor — JSX files
+ // need the object form, otherwise React 19 throws "Failed to set indexed
+ // property [0] on CSSStyleDeclaration" while parsing the string char-by-char.
if (cssContent) {
- replacement.push(indent + '
');
+ const isJsx = commentSyntax.open === '{/*';
+ const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
+ replacement.push(indent + '
');
replacement.push(...restored);
replacement.push(indent + '
');
} else {
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
}
if (inStyle) {
- if (line.trimStart().startsWith('')) break;
+ // Detect anywhere on the line — JSX template-literal closes
+ // (`}`) put the close mid-line, and we don't want to absorb the
+ // template-literal punctuation as CSS content.
+ const closeIdx = line.indexOf('');
+ if (closeIdx !== -1) break;
content.push(line);
}
}
diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js
index 65e779628..78b58180e 100644
--- a/source/skills/impeccable/scripts/live-browser.js
+++ b/source/skills/impeccable/scripts/live-browser.js
@@ -2126,17 +2126,20 @@
}
break;
}
- // HMR didn't propagate in time. Give it a 2s grace window, then
- // reload the page. resumeSession counts variants off the rendered
- // DOM on load and transitions straight to CYCLING — reload is the
- // one universal recovery path: HTML, JSX/TSX, Vue, Svelte, static
- // servers, anything. We used to try DOMParser on the raw source,
- // but JSX expressions aren't valid HTML and the parse fails.
+ // Variants are in source but not in the DOM yet. Common when the
+ // picked element lived inside conditional render (closed modal,
+ // hidden tab, a route the user navigated away from). The variant
+ // MutationObserver stays armed and auto-transitions to CYCLING
+ // the moment the wrapper actually mounts. Nudge the user toward
+ // that path with a toast — better than the prior force-reload
+ // which reset framework state and left the session stuck.
setTimeout(() => {
if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
if (state !== 'GENERATING') return;
- saveSession();
- window.location.reload();
+ showToast(
+ "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
+ 15000,
+ );
}, 2000);
break;
case 'error':
@@ -2236,6 +2239,60 @@
showBar('configure');
startScrollTracking();
maybePrefetchPage();
+ maybeWarnConditionalAncestor(selectedElement);
+ }
+
+ /**
+ * Surface a brief, non-blocking heads-up when the picked element lives
+ * inside a container whose visibility is gated by ephemeral state — modals,
+ * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
+ * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
+ * variants land in source but stay invisible until the user re-opens the
+ * container. Telling the user upfront is much friendlier than the silent
+ * timeout-then-toast that they'd otherwise hit.
+ *
+ * Heuristic, intentionally narrow — only fires for unambiguous cases so
+ * we don't cry wolf on every nested element.
+ */
+ function maybeWarnConditionalAncestor(el) {
+ let node = el?.parentElement;
+ let depth = 0;
+ while (node && depth < 12) {
+ // 1. Active dialog / modal
+ if (node.getAttribute && node.getAttribute('role') === 'dialog'
+ && node.getAttribute('aria-modal') === 'true') {
+ showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 2. Common Radix / shadcn / headless-ui open-state attribute
+ if (node.dataset && node.dataset.state === 'open') {
+ showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
+ return;
+ }
+ // 3. Tab panel — only meaningful when the page also shows ANOTHER
+ // tab as selected. A single tabpanel with no tablist is just a static
+ // section in disguise and isn't conditional.
+ if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
+ const list = document.querySelector('[role="tablist"]');
+ if (list) {
+ const tabs = list.querySelectorAll('[role="tab"]');
+ if (tabs.length > 1) {
+ showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
+ return;
+ }
+ }
+ }
+ // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
+ if (node.id) {
+ const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
+ if (trigger) {
+ showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
+ return;
+ }
+ }
+ node = node.parentElement;
+ depth++;
+ }
}
// Fire a lightweight prefetch event the first time the user selects an
@@ -2694,7 +2751,13 @@ void main() {
const img = document.createElement('img');
img.src = URL.createObjectURL(blob);
img.id = PREFIX + '-shader';
- Object.assign(img.style, canvas.style, { outline: '2px dashed ' + C.brand, outlineOffset: '-2px' });
+ // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
+ // throws in modern Chromium because the source's indexed properties
+ // (style[0], [1], ...) are read-only and the engine forbids writing
+ // them on the destination.
+ img.style.cssText = canvas.style.cssText;
+ img.style.outline = '2px dashed ' + C.brand;
+ img.style.outlineOffset = '-2px';
document.body.appendChild(img);
shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
return;
@@ -4577,6 +4640,18 @@ void main() {
// Check for an active session to resume (variant wrapper already in DOM after HMR)
if (!resumeSession()) {
console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
+ // SvelteKit (and any framework that hydrates after HTML parse) may add
+ // the variant wrapper AFTER init runs. Watch for it and retry resume
+ // once it appears. Disconnect on first hit.
+ const scout = new MutationObserver(() => {
+ const wrapper = document.querySelector('[data-impeccable-variants]');
+ if (!wrapper) return;
+ scout.disconnect();
+ if (resumeSession()) {
+ console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
+ }
+ });
+ scout.observe(document.body, { childList: true, subtree: true });
} else {
console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
}
diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs
index 61614efec..053b9110b 100644
--- a/source/skills/impeccable/scripts/live-inject.mjs
+++ b/source/skills/impeccable/scripts/live-inject.mjs
@@ -88,10 +88,15 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const updated = removeTag(content, config.commentSyntax);
+ const detagged = removeTag(content, config.commentSyntax);
+ const updated = revertCspMeta(detagged);
if (updated === content) return { file: relFile, removed: false, note: 'no tag present' };
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, removed: true };
+ return {
+ file: relFile,
+ removed: detagged !== content,
+ cspReverted: updated !== detagged,
+ };
});
console.log(JSON.stringify({ ok: true, results }));
return;
@@ -109,11 +114,18 @@ Output (JSON):
const absFile = path.resolve(process.cwd(), relFile);
if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' };
const content = fs.readFileSync(absFile, 'utf-8');
- const withoutOld = removeTag(content, config.commentSyntax);
- const updated = insertTag(withoutOld, config, port);
- if (updated === withoutOld) return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ const withoutOld = revertCspMeta(removeTag(content, config.commentSyntax));
+ const withTag = insertTag(withoutOld, config, port);
+ if (withTag === withoutOld) {
+ return { file: relFile, error: 'insertion_point_not_found', anchor: config.insertBefore || config.insertAfter };
+ }
+ const updated = patchCspMeta(withTag, port);
fs.writeFileSync(absFile, updated, 'utf-8');
- return { file: relFile, inserted: true };
+ return {
+ file: relFile,
+ inserted: true,
+ cspPatched: updated !== withTag,
+ };
});
const anyInserted = results.some((r) => r.inserted);
console.log(JSON.stringify({ ok: anyInserted, port, results }));
@@ -296,6 +308,121 @@ function removeTag(content, _syntax) {
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')}"`;
+ const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
+ 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
// ---------------------------------------------------------------------------
@@ -306,3 +433,4 @@ if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs
}
export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.