diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index dedb9df54..249eae44b 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,25 @@ 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. +**Author every `:scope` rule with a descendant combinator.** The `@scope` boundary is the **variant wrapper `
`**, not the element you're designing. A bare `:scope { background: cream; }` styles the wrapper, not the inner replacement, so the cream lands on a `display: contents` shell while the actual element keeps page defaults. Always step in: `:scope > .card`, `:scope > section`, `:scope .hero-title`, etc. The fake test agent's CSS in `tests/live-e2e/agent.mjs` is a faithful template — every rule starts `:scope > ...`. + +**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. @@ -246,6 +268,16 @@ node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .agents/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.agents/skills/impeccable/scripts/live-accept.mjs +++ b/.agents/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.claude/skills/impeccable/scripts/live-accept.mjs +++ b/.claude/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .cursor/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.cursor/skills/impeccable/scripts/live-accept.mjs +++ b/.cursor/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .gemini/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.gemini/skills/impeccable/scripts/live-accept.mjs +++ b/.gemini/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .github/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.github/skills/impeccable/scripts/live-accept.mjs +++ b/.github/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .kiro/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.kiro/skills/impeccable/scripts/live-accept.mjs +++ b/.kiro/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --f Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .opencode/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.opencode/skills/impeccable/scripts/live-accept.mjs +++ b/.opencode/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file RE Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .pi/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.pi/skills/impeccable/scripts/live-accept.mjs +++ b/.pi/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fi Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .rovodev/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.rovodev/skills/impeccable/scripts/live-accept.mjs +++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fi Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .trae-cn/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --file Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .trae/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/.trae/skills/impeccable/scripts/live-accept.mjs +++ b/.trae/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID done --fil Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node .claude/skills/impeccable/scripts/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/plugin/skills/impeccable/scripts/live-accept.mjs b/plugin/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/plugin/skills/impeccable/scripts/live-accept.mjs +++ b/plugin/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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. @@ -246,6 +268,16 @@ node {{scripts_path}}/live-poll.mjs --reply EVENT_ID done --file RELATIVE_PATH Then run `live-poll.mjs` again immediately. +### Aborting an in-flight session + +If wrap or generation fails after the browser has flipped to GENERATING (e.g. wrap landed on the wrong source branch and you've already reverted it, or generation hit an unrecoverable error), tell the **browser** so its bar resets to PICKING: + +```bash +node {{scripts_path}}/live-poll.mjs --reply EVENT_ID error "Short reason" +``` + +Don't run `live-accept --discard` for this — that's a pure file mutator, the browser doesn't see it, and the bar gets stuck on the GENERATING dots forever (the user has to refresh). `--discard` is only correct when the **browser** initiated the discard (user clicked ✕ during CYCLING) and the agent is just running source-side cleanup the browser already triggered. + ## Handle fallback When wrap returns `fallback: "agent-driven"`, the deterministic flow doesn't apply. Pick up here. diff --git a/source/skills/impeccable/scripts/live-accept.mjs b/source/skills/impeccable/scripts/live-accept.mjs index 26bd285e3..f3cb1b484 100644 --- a/source/skills/impeccable/scripts/live-accept.mjs +++ b/source/skills/impeccable/scripts/live-accept.mjs @@ -105,15 +105,22 @@ function handleDiscard(id, lines, targetFile) { if (!block) return { handled: false, error: 'Markers not found' }; 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 + // Restore at the line we're actually replacing FROM, not the marker line. + // For JSX wrappers the marker comments live INSIDE the outer `
`, so + // `block.start` sits 2 spaces deeper than the original element. Using that + // as the deindent base would push the restored content 2 spaces too far + // right on every JSX/TSX session. `replaceRange.start` is the outer wrapper + // line, which is at the original element's indent for both HTML and JSX. + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; 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 {}; @@ -127,8 +134,14 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { const block = findMarkerBlock(id, lines); if (!block) return { handled: false, error: 'Markers not found' }; - const indent = lines[block.start].match(/^(\s*)/)[1]; const commentSyntax = detectCommentSyntax(targetFile); + const isJsx = commentSyntax.open === '{/*'; + // Anchor indent on the line we're replacing FROM (the outer wrapper), + // not on `block.start` — for JSX that's the marker comment 2 spaces + // deeper than the original element. See handleDiscard for the full + // rationale. + const replaceRange = expandReplaceRange(block, lines, isJsx); + const indent = lines[replaceRange.start].match(/^(\s*)/)[1]; // Extract the chosen variant's inner content const variantContent = extractVariant(lines, block, variantNum); @@ -149,7 +162,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 +189,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); @@ -187,9 +198,9 @@ function handleAccept(id, variantNum, lines, targetFile, paramValues) { } 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 +229,72 @@ 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. Operate on JOINED text instead of per-line: a + // multi-line self-closing JSX `` would + // fool per-line regex tracking (the `` line never matches selfCloseRe since it needs `` orphaned after accept/discard. Single regex with + // `[^>]*?` (which spans newlines in JS) handles either form correctly. + const joined = lines.slice(start).join('\n'); + // Match either `
` (self-close, group 1 is `/`), `
` + // (open, group 1 is empty), or `
`. + const tagRe = /]*?(\/?)>|<\/div\s*>/g; + let depth = 0; + let m; + while ((m = tagRe.exec(joined)) !== null) { + const isClose = m[0].startsWith('= end) { + end = candidateEnd; + 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(/