diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index dedb9df54..258a93940 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -74,7 +74,7 @@ Reading annotations precisely: ### 2. Wrap the element ```bash -node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" +node .agents/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div" --text "TEXT_SNIPPET" ``` Flag mapping — keep them separate, don't collapse into `--query`: @@ -82,9 +82,12 @@ Flag mapping — keep them separate, don't collapse into `--query`: - `--element-id` ← `event.element.id` - `--classes` ← `event.element.classes` joined with commas - `--tag` ← `event.element.tagName` +- `--text` ← first ~80 chars of `event.element.textContent` (trim, single-line). **Pass this every call.** When the picked element shares classes + tag with sibling components (a list of ``s, repeating sections), this is what disambiguates which branch in source to wrap. Without it, wrap silently lands on the first match and may rewrite the wrong element. The helper searches ID first, then classes, then tag + class combo. If `event.pageUrl` implies the file (e.g. `/` is usually `index.html`), pass `--file PATH` to skip the search. `--query` is a fallback for raw text search only — do not use it for normal element lookups. +If `--text` matches multiple candidates equally well, wrap exits with `{ error: "element_ambiguous", candidates: [...] }` and `fallback: "agent-driven"` — read the candidate line ranges, decide which one matches the picked element from page context, and write the wrapper manually per the fallback flow. + Output on success: `{ file, insertLine, commentSyntax }`. **Fallback errors.** Wrap only writes into files it judges to be source (tracked by git, not marked GENERATED, not listed in config's `generatedFiles`). If it can't land on a source file, it errors without writing — accepting a variant into a generated file is silent data loss. Three shapes: @@ -181,6 +184,23 @@ The first variant has no `display: none` (visible by default). All others do. If One edit, all variants — the browser's MutationObserver picks everything up in one pass. +**JSX / TSX target files.** Wrap ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.agents/skills/impeccable/scripts/live-accept.mjs +++ b/.agents/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.claude/skills/impeccable/scripts/live-accept.mjs +++ b/.claude/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.cursor/skills/impeccable/scripts/live-accept.mjs +++ b/.cursor/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.gemini/skills/impeccable/scripts/live-accept.mjs +++ b/.gemini/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.github/skills/impeccable/scripts/live-accept.mjs +++ b/.github/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.kiro/skills/impeccable/scripts/live-accept.mjs +++ b/.kiro/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.opencode/skills/impeccable/scripts/live-accept.mjs +++ b/.opencode/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.pi/skills/impeccable/scripts/live-accept.mjs +++ b/.pi/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.rovodev/skills/impeccable/scripts/live-accept.mjs +++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/.trae/skills/impeccable/scripts/live-accept.mjs +++ b/.trae/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/plugin/skills/impeccable/scripts/live-accept.mjs b/plugin/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/plugin/skills/impeccable/scripts/live-accept.mjs +++ b/plugin/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` +
+ {/* variant 1 */} +
+
+ {/* variant 2 */} +
+``` + +The wrap script already gives you a single-rooted JSX wrapper — a `
` outer element with the marker comments tucked inside. Drop the variants block above into the "Variants: insert below this line" comment and the source stays valid TSX. + ### 7. Parameters (composition-sized, 0–4 per variant) Each variant can expose **coarse** knobs alongside the full HTML/CSS replacement. The browser docks a small panel to the right of the outline with one control per parameter. The user drags/clicks and sees instant feedback: there is zero regeneration cost because the knob toggles a CSS variable or data attribute that the variant's scoped CSS is already authored against. diff --git a/source/skills/impeccable/scripts/live-accept.mjs b/source/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..6fcf40940 100644 --- a/source/skills/impeccable/scripts/live-accept.mjs +++ b/source/skills/impeccable/scripts/live-accept.mjs @@ -106,14 +106,16 @@ function handleDiscard(id, lines, targetFile) { const original = extractOriginal(lines, block); const indent = lines[block.start].match(/^(\s*)/)[1]; + const isJsx = detectCommentSyntax(targetFile).open === '{/*'; + const replaceRange = expandReplaceRange(block, lines, isJsx); // De-indent the original content back to the marker's indentation level const restored = deindentContent(original, indent); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...restored, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); return {}; @@ -129,6 +131,7 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +152,6 @@ 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); // 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. @@ -177,7 +179,6 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { // 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) { - const isJsx = commentSyntax.open === '{/*'; const styleAttr = isJsx ? "style={{ display: 'contents' }}" : 'style="display: contents"'; replacement.push(indent + '
'); replacement.push(...restored); @@ -186,10 +187,11 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { replacement.push(...restored); } + const replaceRange = expandReplaceRange(block, lines, isJsx); const newLines = [ - ...lines.slice(0, block.start), + ...lines.slice(0, replaceRange.start), ...replacement, - ...lines.slice(block.end + 1), + ...lines.slice(replaceRange.end + 1), ]; fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8'); @@ -218,6 +220,60 @@ function findMarkerBlock(id, lines) { return (start !== -1 && end !== -1) ? { start, end } : null; } +/** + * Compute the line range to REPLACE (vs. just the marker range to extract + * from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE + * the `
` outer wrapper so the picked + * element's JSX slot keeps a single child — a Fragment `<>` would have + * solved the multi-sibling case but failed inside `asChild` / cloneElement + * parents with "Invalid prop supplied to React.Fragment". + * + * That means the marker block is enclosed by the wrapper `
` opener + * (with `data-impeccable-variants="ID"`) and its matching `
`. We + * walk back to the opener and forward to the closer so accept/discard + * remove the entire scaffold, not just the inner markers. + * + * Marker lines themselves stay where they were so extractOriginal / + * extractVariant / extractCss continue to walk the same range. + */ +function expandReplaceRange(block, lines, isJsx) { + if (!isJsx) return { start: block.start, end: block.end }; + + let { start, end } = block; + + // Walk back for the wrapper `
= Math.max(0, start - 12); i--) { + if (/data-impeccable-variants=/.test(lines[i])) { + let opener = i; + while (opener > 0 && !/` by div-depth tracking from the + // wrapper opener. Self-closing `
` doesn't contribute depth. + const openRe = /]*\/\s*>/g; + const closeRe = /<\/div\s*>/g; + let depth = 0; + for (let i = start; i < lines.length; i++) { + const line = lines[i]; + const opens = (line.match(openRe) || []).length; + const selfCloses = (line.match(selfCloseRe) || []).length; + const closes = (line.match(closeRe) || []).length; + depth += opens - selfCloses - closes; + if (depth <= 0 && i >= end) { + end = i; + break; + } + } + + return { start, end }; +} + /** * Join wrapper lines into a single string with ` and live-accept's + // extractCss used to capture the `{` … `` ` ``}` template-literal punctuation + // as CSS content. handleAccept then re-wrapped with another `{` … + // `` ` ``}`, producing nested template literals (`\n` + + `

variant one

\n` + + `

variant two

\n` + + `
\n` + + ` {/* impeccable-variants-end TPL */}\n` + + ` \n` + + ` \n` + + ` );\n` + + `}\n`; + writeFileSync(join(tmp, 'App.tsx'), tsx); + + const result = runAccept(tmp, ['--id', 'TPL', '--variant', '1']); + assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); + + const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); + // Exactly one `{` opener after the carbonized \n` + + `

variant one

\n` + + `

variant two

\n` + + `
\n` + + ` {/* impeccable-variants-end INLINE */}\n` + + ` \n` + + ` \n` + + ` );\n` + + `}\n`; + writeFileSync(join(tmp, 'App.tsx'), tsx); + + const result = runAccept(tmp, ['--id', 'INLINE', '--variant', '1']); + assert.equal(result.handled, true, `accept should succeed: ${JSON.stringify(result)}`); + + const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8'); + const inner = after.match(/