From c3e18fe664260f09ee5f2fa51bf812e9c1a49b42 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 24 Apr 2026 23:37:45 -0700 Subject: [PATCH] fix(live): four bugs surfaced by E2E suite + CSP meta auto-patch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CSP meta-tag auto-patch (live-inject.mjs) When the user's HTML carries , the cross-origin load of /live.js and the SSE/POST stream back to localhost:PORT are both blocked. Insert: append http://localhost:PORT to script-src and connect-src, plus blob: to img-src (the shader overlay), stash the original content value as a base64 data-impeccable-csp-original attribute. Remove: decode the marker and restore the original verbatim. Header-based CSP (Next/Nuxt/SvelteKit configs) intentionally untouched — those flow through the existing detect-csp.mjs reference path. JSX-aware accept (live-accept.mjs) - Carbonize stash now emits style={{ display: 'contents' }} for JSX targets instead of style="display: contents" (HTML form). React 19 was throwing "Failed to set indexed property [0] on CSSStyleDeclaration" on the string form because it iterated chars onto the style object. - extractCss now matches anywhere on a line, not just at line start. Previously a JSX template-literal close like `} would leak the backtick + brace into the carbonize stash, breaking JSX. - Carbonize stash wraps the CSS body in {` … `} for JSX targets so curly braces in CSS rules don't get parsed as JSX expressions. Conditional-render UX (live-browser.js) - Drop the 2s-then-window.location.reload() fallback in the SSE 'done' handler. That reload was masking a real failure mode: when the picked element lives inside conditional render (closed modal, hidden tab, other-route), Fast Refresh remounts the parent and state resets, so the variants land in source but never reach the DOM. Reload also reset state to default, leaving the user stuck. - Replace with a 6s contextual toast: "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically." The MutationObserver stays armed and auto-transitions to CYCLING once the variants finally mount. - Pick-time heads-up: when the picked element is inside [role="dialog"], [data-state="open"], a multi-tab tabpanel, or an aria-expanded collapsible, fire a brief upfront toast so the user knows what to expect if state resets during generation. Hydration race (live-browser.js) - SvelteKit (and any framework that hydrates after HTML parse) was failing post-Vite-page-reload because init() ran resumeSession() before the variant wrapper hydrated into the DOM. The OLD reload fallback masked this by triggering a second reload whose hydration benefited from warm cache. Without that, fix it properly: install a scout MutationObserver in init() that retries resumeSession() once [data-impeccable-variants] lands in the DOM. Shader overlay (live-browser.js) - WebGL fallback in showShaderOverlay used Object.assign(img.style, canvas.style, …), which throws on modern Chromium because CSSStyleDeclaration's indexed properties are not writable. Use cssText to copy positioning instead. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .pi/skills/impeccable/scripts/live-accept.mjs | 21 ++- .pi/skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .pi/skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- .../skills/impeccable/scripts/live-accept.mjs | 21 ++- .../skills/impeccable/scripts/live-browser.js | 93 ++++++++++-- .../skills/impeccable/scripts/live-inject.mjs | 140 +++++++++++++++++- 36 files changed, 2820 insertions(+), 228 deletions(-) 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.