mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
fix(live): four bugs surfaced by E2E suite + CSP meta auto-patch
CSP meta-tag auto-patch (live-inject.mjs) When the user's HTML carries <meta http-equiv="Content-Security-Policy">, 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 </style> anywhere on a line, not just at line start. Previously a JSX template-literal close like `}</style> 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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
c8de59d81e
commit
c3e18fe664
@@ -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 + '<style data-impeccable-css="' + id + '">');
|
||||
// JSX targets need the CSS body wrapped in a template literal so that the
|
||||
// `{` and `}` in CSS rules don't get parsed as JSX expressions.
|
||||
replacement.push(indent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
|
||||
// Re-indent CSS content to match
|
||||
for (const cssLine of cssContent) {
|
||||
replacement.push(indent + cssLine.trimStart());
|
||||
}
|
||||
replacement.push(indent + '</style>');
|
||||
replacement.push(indent + (isJsx ? '`}</style>' : '</style>'));
|
||||
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 + '<div data-impeccable-variant="' + variantNum + '" style="display: contents">');
|
||||
const isJsx = commentSyntax.open === '{/*';
|
||||
const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"';
|
||||
replacement.push(indent + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
|
||||
replacement.push(...restored);
|
||||
replacement.push(indent + '</div>');
|
||||
} else {
|
||||
@@ -344,7 +353,11 @@ function extractCss(lines, block, id) {
|
||||
}
|
||||
|
||||
if (inStyle) {
|
||||
if (line.trimStart().startsWith('</style>')) break;
|
||||
// Detect </style> anywhere on the line — JSX template-literal closes
|
||||
// (`}</style>`) put the close mid-line, and we don't want to absorb the
|
||||
// template-literal punctuation as CSS content.
|
||||
const closeIdx = line.indexOf('</style>');
|
||||
if (closeIdx !== -1) break;
|
||||
content.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).');
|
||||
}
|
||||
|
||||
@@ -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 `<meta http-equiv="Content-Security-Policy">`,
|
||||
// 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 = /<meta\s+([^>]*?)\/?>/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.
|
||||
|
||||
Reference in New Issue
Block a user