Compare commits

..
Author SHA1 Message Date
Paul Bakaus 6b507e0c6a Release impeccable skill v3.0.5
Live-mode bug-fix release. Closes #114, with thanks again to @dergachoff.

- Live mode lands valid TSX through the wrap → preview → accept →
  carbonize loop on Vite/Next React/TSX projects, including repeated
  sibling branches and JSX `asChild` parents (Radix `<Title>` etc.).
- Wrap correctly disambiguates repeated identical-class siblings via
  the picked element's textContent (`--text` flag).
- Carbonize CSS no longer double-wraps in nested template literals on
  TSX targets.
- JSX accept/discard restores at the picked element's original indent;
  relative depth between lines preserved through the round trip.
- Screenshot overlay during loading no longer flashes solid black on
  default-background pages.
- live-inject's CSP-meta patch+revert byte-for-byte preserves
  self-closing tag whitespace.
- live.md gained explicit guidance on `:scope` descendant combinators
  (authoring trap), the new `--text` flag, JSX `<style>` template-
  literal wrapping, and the agent-side abort path.
2026-04-28 17:45:20 -07:00
Paul BakausandGitHub c1e1104e31 Merge pull request #118 from pbakaus/feat/live-jsx-wrap-and-carbonize
fix(live): land valid TSX through wrap → preview → accept → carbonize
2026-04-28 17:34:00 -07:00
Paul BakausandClaude Opus 4.7 1f760aff61 fix(live): expandReplaceRange handles multi-line self-closing JSX <div />
Cursor Bugbot review on 8660d3a flagged a real corruption bug:

> Multi-line self-closing div breaks depth tracking in expandReplaceRange.
> The forward div-depth walk applies openRe / selfCloseRe / closeRe
> per-line. A multi-line `<div\n  className="spacer"\n/>` causes openRe
> to match the opener line but selfCloseRe fails on both lines because
> `/<div\b[^>]*\/\s*>/` requires the full tag on one line. Depth is
> permanently over-counted by 1, so the walk overshoots.

Trace on the JSX-marker-inside-wrapper layout:
- Inside the wrapped element, a multi-line `<div … />` increments depth
  at the `<div` line and never decrements.
- Forward walk's depth never returns to 0 → end stays at block.end (the
  inner marker comment) → replace range stops there.
- Wrapper's outer `</div>` is left orphaned in the file after
  accept/discard, breaking the JSX. Worse: an unrelated subsequent
  `<div className="next-card">…</div>` sibling gets its `</div>`
  mis-counted as the wrapper close, and the depth walk corrupts further.

Fix: rewrite the forward walk on JOINED text instead of per-line. A
single regex `/<div\b[^>]*?(\/?)>|<\/div\s*>/g` spans newlines (because
`[^>]` matches `\n`), so it correctly identifies multi-line opens,
closes, AND self-closes. Convert the match offset back to a file line
index to set `end`. Walk-back logic for the wrapper opener is
unchanged.

Test coverage:
- New `expandReplaceRange handles multi-line self-closing <div />` test
  in live-accept.test.mjs constructs the exact Bugbot scenario: a
  multi-line `<div\n  className="spacer"\n/>` inside the picked
  element AND an unrelated `<div className="next-card">After</div>`
  sibling right after. Asserts the discard removes ALL impeccable
  markers / wrapper attrs, preserves the next-card sibling intact, and
  the multi-line `<div />` survives inside the restored content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 17:19:07 -07:00
Paul BakausandClaude Opus 4.7 8660d3aa22 fix(live): wrap shape-of-output bugs from second Bugbot review
Two more Cursor Bugbot findings on commit 11dfad81:

1. `filterByText` short-text returned the wrong sentinel value.
   When the trimmed snippet was shorter than 8 chars, the function
   returned `candidates.slice()` (all candidates). The caller then
   sees `filtered.length > 1` and fires `element_ambiguous` — exactly
   the opposite of the documented short-text fallback ("caller falls
   back to first-match," which corresponds to `filtered.length === 0`).
   So any picker event with a short textContent on a page with multiple
   matching siblings spuriously errored.
   Fix: return `[]` for short text, matching the JSDoc.

2. `endLine` in the wrap output was wrong for multi-line picked elements.
   `wrapperLines.length` counts ARRAY elements, but one element is a
   `\n`-joined multi-line string (originalIndented). The actual
   wrapper-region row count is `wrapperLines.length + (originalLines.length
   - 1)`. Reporting `endLine = startLine + wrapperLines.length` placed
   the boundary inside the wrapper for any multi-line pick, giving
   downstream agents an incorrect range.
   Fix: add the originalLines offset (matching what `insertLine` already
   does after the prior commit).

Test coverage:
- `short --text falls back to first-match instead of erroneously firing
  element_ambiguous` covers fix #1.
- `returns endLine that includes the multi-line original content offset`
  covers fix #2 by wrapping a 5-line <section> in a real HTML file and
  asserting the reported endLine points at the variants-end marker (and
  the next line is </main>, proving no rows were missed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:52:02 -07:00
Paul BakausandClaude Opus 4.7 11dfad81da fix(live): CSP-meta patch+revert preserves space before self-closing /
Sanity-check on the live-inject unwrap path turned up a real round-trip
bug on HTML files that ship a `<meta http-equiv="Content-Security-Policy"
content="..." />` tag (the leading space before `/>` is the canonical
self-closing form).

Trace:
- The tag-finder regex (`<meta\s+([^>]*?)\/?>`) captures any whitespace
  between the last attribute and the closing `/>` as part of `attrs`.
- patchCspMeta did `attrs.replace(content, newContent) + ' ' + marker`,
  appending the marker AFTER that captured trailing whitespace. Result:
  `...content="..."  data-...="..."` — a double space inside attrs and
  the original space-before-slash gone.
- revertCspMeta then strips the marker via `\s*${origAttr.full}`, which
  greedily eats both spaces — so the round trip leaves `"/>` with no
  space, even though the original was `" />`.

Fix: split off the trailing whitespace from `attrs` before patching,
splice the marker into the attribute body with a single leading space,
and re-append the original trailing whitespace. The marker-removal
regex then consumes exactly one space and the trailing space rides
through unchanged.

Test coverage:
- New `round-trips through CSP-meta patch and revert` test in
  live-inject.test.mjs covers the canonical Vite shape (CSP meta with
  ` />`).
- Plus a `round-trips with insertAfter` test for symmetry — the existing
  suite only covered insertBefore.
- Existing 4 round-trip tests (HTML, JSX layout, multi-file, column-0)
  all still pass byte-for-byte.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 16:03:14 -07:00
Paul BakausandClaude Opus 4.7 a701ee613a fix(live): wrap preserves relative indent of multi-line picked elements
Companion to the prior outer-indent fix. live-wrap.mjs's
`originalLines.map(l => indent + '    ' + l.trimStart())` calls
`trimStart()` on every line, which strips ALL leading whitespace and
collapses multi-line picked elements to a uniform indent. So a 6/8/6
shape like

    <aside className="card">
      <h1 className="hero-title">Hero</h1>
    </aside>

was being reindented to 10/10/10 inside the wrapper, and on
accept/discard the round-trip restored 6/6/6 — the <h1> ended up at
its parent's depth instead of nested inside it.

Fix: extract `minLeadingSpaces(lines)` and strip only the COMMON
minimum across the picked lines before reindenting under the wrapper.
That mirrors how `deindentContent` on the accept side already works,
so wrap+accept now form a clean round-trip.

Test coverage:
- Expanded the indent regression test in live-accept.test.mjs to
  also assert the inner `<h1>` at 8-space indent and the closing
  `</aside>` at 6 — proving the relative depth survives wrap and
  discard end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:59:07 -07:00
Paul BakausandClaude Opus 4.7 99e68370b9 fix(live): JSX accept/discard restores at original indent (Bugbot review)
Cursor Bugbot caught this on PR #118 review:

> JSX discard/accept restores content with wrong indentation. In the JSX
> path, `indent` is captured from `lines[block.start]` — the marker comment
> line inside the wrapper div, which is indented 2 extra spaces relative
> to the original element. But `expandReplaceRange` expands the replacement
> to include the outer `<div data-impeccable-variants>` wrapper, which sits
> at the original element's indent level. `deindentContent(original, indent)`
> restores content to the marker's deeper indent, so all restored lines end
> up 2 spaces deeper than the original element was.

I'd actually noticed the symptom during the live testing session ("some
odd indentation in card-2 after discard") and dismissed it as cosmetic.
Bugbot's analysis matches exactly.

Fix: anchor the deindent base on `replaceRange.start` instead of
`block.start`. For HTML the two are identical (markers sit outside the
wrapper), so HTML is unchanged. For JSX `replaceRange.start` is the
outer `<div>` at the original element's indent — correct base.

Also dropped a duplicate `expandReplaceRange` call in handleAccept that
the earlier edit left orphaned.

Test coverage:
- Two new regression tests in live-accept.test.mjs:
  - `discard restores JSX content at the original indent` runs the
    real wrap CLI and asserts the restored <aside> opener lands at
    its original 6-space indent (was 8 before the fix).
  - `accept (no carbonize, raw HTML) restores at the original indent
    on JSX` exercises the same anchor on the accept path.
- Inner-element indent loss inside the wrapped content (`<h1>` ending
  up at the same indent as its parent `<aside>`) is a separate,
  pre-existing wrap behavior — left for a follow-up; explicitly
  noted in the test comments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:55:27 -07:00
Paul BakausandClaude Opus 4.7 fdb9e7c6f8 fix(live): screenshot overlay no longer flashes solid black during loading
Same alpha-string trap pattern as the recent detectPageTheme fix, on a
different code path. resolveCanvasBackground walks parents looking for
an opaque background; on a page that doesn't set its own bg the loop
runs out and fell through to:

  return getComputedStyle(document.body).backgroundColor
    || getComputedStyle(document.documentElement).backgroundColor
    || '#ffffff';

`getComputedStyle(body).backgroundColor` for a default-bg page returns
the literal string "rgba(0, 0, 0, 0)" — non-empty, truthy — so the `||`
chain short-circuits to transparent-black instead of falling through to
'#ffffff'. modern-screenshot then composites the capture onto a black
canvas; the WebGL shader overlay flashes solid black until the shader
finishes loading.

Fix: drop the buggy fallback. The while-loop already covered <body> and
<html>; if neither is opaque the only sensible answer is the browser's
default canvas color (white).

Test coverage:
- New tests/live-browser-regression.test.mjs pins the anti-pattern
  with a static-source check (live-browser.js is an IIFE with no module
  exports, so this is the cheapest reliable regression guard). Also
  pins the equivalent guard for detectPageTheme's readOpaque helper
  added in the prior commit.
- Wired the new test file into `bun run test`'s explicit list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:40:24 -07:00
Paul BakausandClaude Opus 4.7 9ec904302b fix(live): textContent disambiguation handles missing inter-element whitespace
While driving the new live loop end-to-end against the repeated-aside
fixture, --text disambiguation silently fell back to first-match instead
of landing on the picked card.

Root cause: `el.textContent` concatenates child text nodes without
inserting whitespace, so `<h1>Hero Two</h1><p>Second card body copy.</p>`
reads as "Hero TwoSecond card body copy." — but the source has whitespace
between </h1> and <p>. The single-space normalization on both sides
missed the join boundary; substring comparison failed; filterByText
returned [] and the caller fell through to first-match.

Fix: filterByText now compares both single-space AND no-whitespace
normalizations on each side, accepting the candidate if EITHER matches.
Bumped the minimum-target-length threshold from 6 to 8 to compensate
for the slightly looser comparison.

Plus two doc clarifications surfaced during the same session:

- live.md now warns that variant CSS using bare `:scope { ... }` styles
  the variant wrapper div, not the picked element. Always use a
  descendant combinator (`:scope > .card`, `:scope .hero-title`, etc.) —
  the fake test agent's CSS is the canonical template.
- live.md documents the agent-side abort path. Aborting an in-flight
  generate via `live-accept --discard` only mutates source — the browser
  bar stays in GENERATING forever. Use `live-poll --reply EVENT_ID error
  "msg"` instead so the browser receives the error SSE and resets.

Test coverage:
- New unit test in live-wrap.test.mjs covering the textContent-without-
  inter-element-whitespace shape (three identical <aside> branches each
  with <h1> + <p>, picks the second by --text).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:03:49 -07:00
Paul BakausandClaude Opus 4.7 54d9f05ea5 fix(live): land valid TSX through wrap → preview → accept → carbonize
Closes #114.

Three orthogonal bugs that surfaced together when live mode picked an
element inside a Vite React/TSX component with sibling branches:

1. JSX wrapper insertion produced invalid TSX
   - Replacing a single picked JSX child with [comment, <div>, comment]
     yields three adjacent siblings, which oxc rejects with "Adjacent
     JSX elements must be wrapped in an enclosing tag."
   - A Fragment `<></>` solves the adjacency case but breaks
     `cloneElement`-using parents (Radix `asChild`, Headless UI, etc.)
     with "Invalid prop supplied to React.Fragment."
   - Fix: keep the wrapper `<div data-impeccable-variants="ID">` as the
     single JSX-slot child and tuck both marker comments INSIDE it.
     accept/discard now expands its replacement range to include the
     wrapper's `<div>` open/close lines via div-depth tracking.

2. carbonize produced nested template literals in TSX `<style>`
   - extractCss captured `{` / `` `} `` lines from the agent's existing
     `<style>{`…`}</style>` template, then handleAccept re-wrapped with
     another pair, producing `<style>{`{`@scope…`}`}</style>` which oxc
     rejects with "Expected `}` but found `@`".
   - Fix: extractCss now strips a leading `{` and trailing `` `} ``
     wherever they appear in the captured content (own line OR attached
     to the first/last CSS line), so re-wrapping always yields exactly
     one `{` ` … ` `}` pair.

3. Ambiguous source matching for repeated JSX branches
   - `findElement` returned the first substring match. Multiple
     `<aside className="card">` siblings all matched the same query, so
     wrap silently landed on the first regardless of which one the user
     picked.
   - Fix: live-wrap accepts `--text TEXT` (the picked element's
     textContent), collects ALL candidates via `findAllElements`, and
     narrows by a tag-stripped, JSX-expression-stripped substring match.
     Returns `element_ambiguous + candidates[]` when multiple branches
     match equally; falls back to first-match when source uses dynamic
     content (`<h1>{title}</h1>`) so existing flows aren't broken.
   - The fake e2e agent now forwards `event.element.textContent` to
     wrap, and live.md tells the agent to do the same.

Test coverage:
- New `vite8-react-tsx-repeated-aside` e2e fixture: three identical
  `<aside>` branches, picks the second card's <h1>, runs the full
  wrap → Go → cycle → accept → carbonize cycle on a real Vite + TSX
  dev server, asserts that Hero One and Hero Three survive untouched
  (proving wrap landed on the correct branch).
- Six new unit tests across live-wrap.test.mjs and live-accept.test.mjs
  covering the Fragment-replacement design, both leading/trailing
  template-literal placements, --text disambiguation, the dynamic-
  content fallback, and the element_ambiguous error shape.
- New `runtime.assertSourceContains` fixture hook so other regression
  fixtures can assert sibling-branch survivability cheaply.

All 186 unit + static-fixture tests pass; all 21 live e2e fixtures
(20 prior + new TSX) pass with no console errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 13:23:20 -07:00
Paul BakausandClaude Opus 4.7 638af20566 Document the release workflow in CLAUDE.md and AGENTS.md
Covers the per-component tag prefixes, the changelog-label convention
that the release script matches against, the cleanliness gates, the
attached artifacts, and the manual post-release steps for the CLI
(npm publish) and the extension (Chrome Web Store upload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 11:03:34 -07:00
98 changed files with 5814 additions and 473 deletions
+33 -1
View File
@@ -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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines — all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate — the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -12,7 +12,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.0.4",
"version": "3.0.5",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.0.4",
"version": "3.0.5",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .claude/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines — all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate — the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
---
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .cursor/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .cursor/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
---
Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .gemini/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .gemini/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .github/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .github/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
---
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .kiro/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .kiro/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .opencode/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .opencode/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
allowed-tools:
- Bash(npx impeccable *)
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .pi/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .pi/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
+10 -1
View File
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .rovodev/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .rovodev/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .trae-cn/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .trae-cn/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .trae/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .trae/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+4
View File
@@ -51,6 +51,10 @@ Conventions: wrap the identifying heading text in straight double quotes inside
Recent history favors short, imperative subjects such as `Fix: ...`, `Add ...`, `Improve ...`, or `Bump ...`. Keep commits focused and explain the user-facing impact when it is not obvious. PRs should summarize what changed, list validation performed, and call out regenerated artifacts like `dist/` or `build/`. Include screenshots for visible `public/` changes and mention affected providers when transform behavior changes.
## Releases
Tags are per-component because the three components ship independently: `skill-v` (`.claude-plugin/plugin.json` + `.claude-plugin/marketplace.json`), `cli-v` (`package.json`), `ext-v` (`extension/manifest.json`). Flow: bump the relevant manifest, add a changelog entry to `public/index.html` (skill = bare `vX.Y.Z`; CLI = `CLI vX.Y.Z`; extension = `Extension vX.Y.Z` — the prefix is how `scripts/release.mjs` finds the right block), commit, push, then `bun run release:<skill|cli|ext>` (or `--dry-run` first). The script refuses on a dirty tree, an unpushed HEAD, a missing changelog entry, or stale build outputs; skill and extension reruns of `bun run build` / `bun run build:extension` must produce zero diff. Skill releases attach `dist/universal.zip`; extension releases attach `dist/extension.zip`. CLI ships to npm via a separate `npm publish`, and the extension zip uploads to the Chrome Web Store manually — both reminded at the end of the script. Fix already-shipped notes with `gh release edit <tag> --notes-file <md>`.
## Contributor Notes
Do not edit generated provider files directly unless you are intentionally patching generated output as part of a build-system change. Prefer fixing the root source in `source/`, `scripts/`, or `src/`, then regenerate artifacts.
+19
View File
@@ -174,6 +174,25 @@ There are three independently versioned components. Only bump the one(s) that ac
- Update for user-facing changes only, not internal build/tooling details
- Use the most prominent version that changed (skills version is usually the right one)
After bumping, see **Releases** below for how to tag and publish.
## Releases
GitHub releases are tagged per-component, not per-version, since the three components ship independently. Tag prefixes: `skill-v`, `cli-v`, `ext-v`.
Workflow for any component:
1. Bump the manifest version (see Versioning above).
2. Add a changelog entry to `public/index.html`. Skill entries use a bare `vX.Y.Z` label; CLI and extension entries use the prefixed forms `CLI vX.Y.Z` and `Extension vX.Y.Z`. The release script extracts notes by matching this label, so the prefix matters.
3. Commit and push to `main`.
4. Run `bun run release:<skill|cli|ext>`. Preview first with `node scripts/release.mjs <component> --dry-run`.
The script refuses to run if: the working tree is dirty, HEAD is ahead of origin, the tag already exists, the matching changelog entry is missing, or (for skill/extension) `bun run build` / `bun run build:extension` produces uncommitted changes — meaning the harness output dirs or `extension/detector/` files weren't refreshed before the bump was committed.
Skill releases attach `dist/universal.zip`. Extension releases run `bun run build:extension` first and attach `dist/extension.zip`. CLI releases print a reminder to run `npm publish` separately; extension releases print a reminder to upload the zip to the Chrome Web Store dashboard.
If you need to fix release notes after the fact (typo, missing thank-you, formatting bug): `gh release edit <tag> --notes-file <md>`. The release script's `htmlToMarkdown` function is the cleanest source for regenerating notes from the changelog.
## Adding New Commands
All commands live under `/impeccable`. To add a new one:
+1 -1
View File
@@ -49,7 +49,7 @@
"dev": "bun run server/index.js",
"preview": "bun run build && wrangler pages dev",
"deploy": "bun run build && wrangler pages deploy build/",
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/framework-fixtures.test.mjs",
"test": "bun test tests/build.test.js tests/detect-antipatterns.test.js tests/windows-path-fix.test.js && node --test tests/detect-antipatterns-fixtures.test.mjs && node --test tests/detect-antipatterns-browser.test.mjs && node --test tests/cleanup-deprecated.test.mjs && node --test tests/live-wrap.test.mjs && node --test tests/live-accept.test.mjs && node --test tests/live-inject.test.mjs && node --test tests/live-server.test.mjs && node --test tests/live-browser-regression.test.mjs && node --test tests/framework-fixtures.test.mjs",
"test:live-e2e": "node --test --test-timeout=600000 tests/live-e2e.test.mjs",
"prepack": "cp README.md README.repo.md && cp README.npm.md README.md",
"postpack": "cp README.repo.md README.md && rm README.repo.md",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "impeccable",
"description": "Design fluency for frontend development. 1 skill with 23 commands (/impeccable polish, /impeccable audit, /impeccable critique, etc.) and curated anti-pattern detection.",
"version": "3.0.4",
"version": "3.0.5",
"author": {
"name": "Paul Bakaus",
"email": "paul@paulbakaus.com"
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: impeccable
description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
version: 3.0.4
version: 3.0.5
user-invocable: true
argument-hint: "[craft|shape · audit|critique · animate|bolder|colorize|delight|layout|overdrive|quieter|typeset · adapt|clarify|distill · harden|onboard|optimize|polish · teach|document|extract|live] [target]"
license: Apache 2.0. Based on Anthropic's frontend-design skill. See NOTICE.md for attribution.
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node .claude/skills/impeccable/scripts/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node .claude/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
+14 -1
View File
@@ -137,7 +137,7 @@
</div>
<p class="hero-version-link"><a href="#changelog">v3.0.4: stricter craft mock fidelity</a></p>
<p class="hero-version-link"><a href="#changelog">v3.0.5: live mode lands valid TSX in React/JSX projects</a></p>
</div>
@@ -944,6 +944,19 @@
</ul>
</div>
<div class="changelog-entry">
<div class="changelog-version-header">
<span class="changelog-version">v3.0.5</span>
<span class="changelog-date">April 28, 2026</span>
</div>
<ul class="changelog-items">
<li><strong>Live mode lands valid TSX through the wrap → preview → accept → carbonize loop on Vite/Next React/TSX projects.</strong> The wrapper now keeps a single JSX-slot child instead of three adjacent siblings, so it round-trips cleanly inside <code>return (...)</code>, array <code>.map(...)</code>, and <code>asChild</code> parents like Radix's <code>&lt;DialogPrimitive.Title&gt;</code>. Carbonize stopped double-wrapping CSS in nested template literals on TSX targets, accept and discard restore the picked element at its original indent (with relative depth between lines preserved), and the screenshot overlay no longer flashes solid black on default-background pages. Closes <a href="https://github.com/pbakaus/impeccable/issues/114" target="_blank" rel="noopener">#114</a>, with thanks again to <a href="https://github.com/dergachoff" target="_blank" rel="noopener">@dergachoff</a> for the detailed bug report.</li>
<li><strong>Wrap correctly disambiguates repeated identical-class siblings.</strong> A list of <code>&lt;Card className="card"&gt;</code> rendered three times with the same classes used to land on the first one regardless of which the user picked. <code>live-wrap.mjs</code> now accepts <code>--text TEXT</code> (the picked element's <code>textContent</code>) and narrows candidates accordingly, falling back to first-match when the text is too short or doesn't appear literally in source (data-driven children like <code>{title}</code>), and erroring with <code>element_ambiguous</code> when multiple branches still match equally.</li>
<li><strong><code>live-inject</code> CSP-meta unwrap now byte-for-byte preserves self-closing tag whitespace.</strong> The patch+revert cycle on a <code>&lt;meta http-equiv="Content-Security-Policy" ... /&gt;</code> tag was eating the space before <code>/&gt;</code> via a double-space artifact in the marker insertion path; common Vite shapes that ship a CSP meta now round-trip cleanly.</li>
<li><strong><code>live.md</code> spec gained explicit guidance for three real authoring traps.</strong> Variant CSS must use a descendant combinator (<code>:scope &gt; .card</code>, not bare <code>:scope</code>) or it lands on the wrapper instead of the picked element. JSX <code>&lt;style&gt;</code> bodies need <code>{`...`}</code> template-literal wrapping. Aborting an in-flight session uses <code>live-poll --reply EVENT_ID error "msg"</code>, not <code>live-accept --discard</code>; the latter only mutates source while the bar stays stuck on GENERATING dots forever.</li>
</ul>
</div>
<div class="changelog-entry">
<div class="changelog-version-header">
<span class="changelog-version">v3.0.4</span>
+33 -1
View File
@@ -74,7 +74,7 @@ Reading annotations precisely:
### 2. Wrap the element
```bash
node {{scripts_path}}/live-wrap.mjs --id EVENT_ID --count EVENT_COUNT --element-id "ELEMENT_ID" --classes "class1,class2" --tag "div"
node {{scripts_path}}/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 `<Card>`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 `<div data-impeccable-variant="N">`**, 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 `<style>` content in a template literal so the CSS `{` / `}` aren't parsed as JSX expressions, and use `className=` / `style={{…}}` on every variant element. Keep `data-impeccable-*` attributes as-is — they're plain strings:
```tsx
<style data-impeccable-css="SESSION_ID">{`
@scope ([data-impeccable-variant="1"]) { ... }
@scope ([data-impeccable-variant="2"]) { ... }
`}</style>
<div data-impeccable-variant="1">
{/* variant 1 */}
</div>
<div data-impeccable-variant="2" style={{ display: 'none' }}>
{/* variant 2 */}
</div>
```
The wrap script already gives you a single-rooted JSX wrapper — a `<div data-impeccable-variants="…">` 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, 04 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.
+141 -11
View File
@@ -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 `<div>`, 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 + '<div data-impeccable-variant="' + variantNum + '" ' + styleAttr + '>');
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 `<div data-impeccable-variants="ID">` 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 `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. 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 `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= Math.max(0, start - 12); i--) {
if (/data-impeccable-variants=/.test(lines[i])) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener])) opener--;
start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` 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 `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
@@ -345,7 +422,7 @@ function extractCss(lines, block, id) {
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = sameLine[1];
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
@@ -362,7 +439,60 @@ function extractCss(lines, block, id) {
}
}
return content.length > 0 ? content : null;
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
@@ -2622,11 +2622,15 @@
if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
node = node.parentElement;
}
return (
getComputedStyle(document.body).backgroundColor ||
getComputedStyle(document.documentElement).backgroundColor ||
'#ffffff'
);
// The walk already passed through <body> and <html>; if they had been
// opaque we would have returned. Falling through with the previous
// `getComputedStyle(body).backgroundColor || …` chain is a trap: that
// call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
// never set its own bg, which is truthy and short-circuits the chain to
// transparent-black — modern-screenshot then renders the capture on a
// black canvas and the shader overlay flashes solid black during load.
// The browser canvas defaults to white, so we do too.
return '#ffffff';
}
// Capture the element (with current annotations baked in) and return a PNG
@@ -388,7 +388,16 @@ export function patchCspMeta(content, port) {
const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
const newAttrs = attrs.replace(contentAttr.full, newContentAttr) + ' ' + marker;
// The tagRe captures any whitespace between the last attribute and the
// closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
// a replace would land it BEFORE that trailing space, leaving a double
// space inside attrs and clobbering the space before `/>`. Split off
// the trailing whitespace, splice the marker into the attribute body,
// and re-append the original trailing whitespace so a self-closing
// `<meta … />` round-trips byte-for-byte.
const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
const newTag = tag.full.replace(attrs, newAttrs);
result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+193 -17
View File
@@ -37,6 +37,10 @@ Element identification (at least one required):
Optional:
--file PATH Source file to search in (skips auto-detection)
--text TEXT Picked element's textContent. Used to disambiguate when
classes/tag match multiple sibling elements (e.g. a list
of <Card>s with the same className). Pass the first ~80
chars of event.element.textContent.
--help Show this help message
Output (JSON):
@@ -53,6 +57,7 @@ The agent should insert variant HTML at insertLine.`);
const tag = argVal(args, '--tag');
const query = argVal(args, '--query');
const filePath = argVal(args, '--file');
const text = argVal(args, '--text');
if (!id) { console.error('Missing --id'); process.exit(1); }
if (!elementId && !classes && !query) {
@@ -115,17 +120,67 @@ The agent should insert variant HTML at insertLine.`);
const content = fs.readFileSync(targetFile, 'utf-8');
const lines = content.split('\n');
// Find the element, trying each query in priority order.
// Pass tag hint so findElement can reject matches inside wrong element types
// and walk backward to the real opener on multi-line JSX tags.
// Find the element, trying each query in priority order. When `--text` is
// supplied, collect every candidate the queries surface and disambiguate
// by the picked element's textContent. Without `--text`, fall back to the
// legacy first-match behavior so unmodified callers keep working.
let match = null;
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
if (text) {
const candidates = [];
for (const q of queries) {
const all = findAllElements(lines, q, tag);
for (const c of all) {
if (!candidates.some((x) => x.startLine === c.startLine)) {
candidates.push(c);
}
}
// Once a more-specific query (ID, full className combo) yielded a unique
// result, stop — falling through to the loose tag+single-class query
// would readmit the siblings we just disambiguated past.
if (candidates.length === 1) break;
}
if (candidates.length === 0) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
if (candidates.length === 1) {
match = candidates[0];
} else {
const filtered = filterByText(candidates, lines, text);
if (filtered.length === 1) {
match = filtered[0];
} else if (filtered.length === 0) {
// Source uses dynamic content (`<h1>{title}</h1>` etc.) so the
// browser-side textContent doesn't appear literally in source. Fall
// back to first-match rather than refusing — this is the same
// behavior unmodified callers see, just preserved.
match = candidates[0];
} else {
// Multiple candidates ALSO match the text. Truly ambiguous — refuse
// rather than pick wrong, and hand the agent the candidate locations
// so it can disambiguate by reading the file.
console.error(JSON.stringify({
error: 'element_ambiguous',
fallback: 'agent-driven',
file: path.relative(process.cwd(), targetFile),
candidates: filtered.map((c) => ({
startLine: c.startLine + 1,
endLine: c.endLine + 1,
})),
hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
}));
process.exit(1);
}
}
} else {
for (const q of queries) {
match = findElement(lines, q, tag);
if (match) break;
}
if (!match) {
console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
process.exit(1);
}
}
const { startLine, endLine } = match;
@@ -133,17 +188,48 @@ The agent should insert variant HTML at insertLine.`);
const isJsx = commentSyntax.open === '{/*';
const indent = lines[startLine].match(/^(\s*)/)[1];
// Extract the original element
// Extract the original element. Reindent under the wrapper while preserving
// the relative depth between lines — `l.trimStart()` would strip ALL leading
// whitespace and collapse e.g. `<aside>`/` <h1>`/`</aside>` (6/8/6 spaces)
// to a single uniform indent, so on accept/discard the round-trip restores
// the inner element at its parent's depth instead of nested inside it.
// Strip only the COMMON minimum leading whitespace across the picked lines;
// `deindentContent` on the accept side already mirrors this convention.
const originalLines = lines.slice(startLine, endLine + 1);
const originalIndented = originalLines.map(l => indent + ' ' + l.trimStart()).join('\n');
const originalBaseIndent = minLeadingSpaces(originalLines);
const reindentOriginal = (extra) => originalLines
.map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
.join('\n');
const originalIndented = reindentOriginal(' ');
// Wrapper attributes differ by syntax. HTML allows plain string attrs;
// JSX requires object-literal style and parses string attrs as HTML (which
// either type-errors or renders a literal CSS string).
const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
// Build the wrapper
const wrapperLines = [
// JSX/TSX guard: the picked element occupies a single JSX child slot
// (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
// any other expression position). Replacing it with `comment + <div> +
// comment` yields three adjacent siblings — invalid JSX. We can't use a
// Fragment `<></>` either: parents that clone children (Radix `asChild`,
// Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
// they try to pass an `id` through.
//
// Solution: keep the wrapper `<div>` as the single JSX-slot child and
// tuck both marker comments INSIDE it. accept/discard then expands its
// replacement range to include the wrapper's `<div>` open / close lines
// so the entire scaffold gets removed cleanly.
const wrapperLines = isJsx ? [
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
indent + ' <div data-impeccable-variant="original">',
reindentOriginal(' '),
indent + ' </div>',
indent + ' ' + commentSyntax.open + ' Variants: insert below this line ' + commentSyntax.close,
indent + ' ' + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
indent + '</div>',
] : [
indent + commentSyntax.open + ' impeccable-variants-start ' + id + ' ' + commentSyntax.close,
indent + '<div data-impeccable-variants="' + id + '" data-impeccable-variant-count="' + count + '" ' + styleContents + '>',
indent + ' ' + commentSyntax.open + ' Original ' + commentSyntax.close,
@@ -163,13 +249,24 @@ The agent should insert variant HTML at insertLine.`);
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
// Calculate insert line (the "insert below this line" comment)
const insertLine = startLine + 6; // 0-indexed in the new file
// Calculate insert line (the "insert below this line" comment).
// 0-indexed file position. Both HTML and JSX wrappers have 6 lines above
// the insert marker (HTML: start-comment + outer-div + Original-comment +
// original-div + content + close-original-div; JSX: outer-div +
// start-comment + Original-comment + original-div + content +
// close-original-div). Multi-line originals push the marker by their
// extra line count.
const insertLine = startLine + 6 + (originalLines.length - 1);
console.log(JSON.stringify({
file: path.relative(process.cwd(), targetFile),
startLine: startLine + 1, // 1-indexed for the agent
endLine: startLine + wrapperLines.length, // 1-indexed
// wrapperLines is an array but one element (the original-content slot)
// is a `\n`-joined multi-line string, so the actual file-row count is
// wrapperLines.length + (originalLines.length - 1). Without the offset,
// endLine pointed inside the wrapper for any picked element that
// spanned more than one source line.
endLine: startLine + wrapperLines.length + (originalLines.length - 1), // 1-indexed
insertLine: insertLine + 1, // 1-indexed: where variants go
commentSyntax: commentSyntax,
originalLineCount: originalLines.length,
@@ -310,6 +407,22 @@ const OPENER_RE = /<([A-Za-z][A-Za-z0-9]*)(?=[\s/>]|$)/;
* line to find the actual tag opener. When `tag` is provided, opener candidates
* must match that tag name.
*/
/**
* Return the smallest leading-whitespace count across a set of lines,
* ignoring blank lines (whose indent isn't load-bearing). Used to compute
* the common base indent of a multi-line picked element so reindenting
* under the wrapper preserves the relative depth between lines.
*/
function minLeadingSpaces(lines) {
let min = Infinity;
for (const l of lines) {
if (l.trim() === '') continue;
const m = l.match(/^(\s*)/);
if (m && m[1].length < min) min = m[1].length;
}
return min === Infinity ? 0 : min;
}
function findElement(lines, query, tag = null) {
// Iterate all matches — the first substring hit isn't always the right one.
for (let i = 0; i < lines.length; i++) {
@@ -330,6 +443,69 @@ function findElement(lines, query, tag = null) {
return null;
}
/**
* Like findElement, but returns every match. Used for ambiguity detection
* when the agent passes --text: when the same className appears on multiple
* sibling elements (a list of cards, repeated section variants, etc.),
* first-match silently lands on the wrong branch. Returning all matches lets
* the caller narrow by textContent or fail with a structured ambiguity error.
*/
function findAllElements(lines, query, tag = null) {
const out = [];
const seen = new Set();
for (let i = 0; i < lines.length; i++) {
if (!lines[i].includes(query)) continue;
const stripped = lines[i].trim();
if (stripped.startsWith('<!--') || stripped.startsWith('{/*') || stripped.startsWith('//')) continue;
if (lines[i].includes('data-impeccable-variant')) continue;
const openerLine = findOpenerLine(lines, i, tag);
if (openerLine === -1) continue;
if (seen.has(openerLine)) continue; // multiple matches inside the same element
seen.add(openerLine);
const endLine = findClosingLine(lines, openerLine);
out.push({ startLine: openerLine, endLine });
}
return out;
}
/**
* Narrow a candidate set to those whose source body matches a meaningful
* prefix of the picked element's textContent. The compare strips tags and
* JSX expressions, then checks two whitespace normalizations side-by-side:
*
* - single-space ("hero two second card body")
* - no-whitespace ("herotwosecondcardbody")
*
* Both are needed because `el.textContent` concatenates sibling text without
* inserting whitespace (e.g. `<h1>Hero Two</h1><p>Second…</p>` reads as
* `"Hero TwoSecond…"`), while the source has whitespace between tags. If
* EITHER normalization matches, the candidate keeps. A snippet shorter than
* 8 chars after stripping is too weak to disambiguate the caller falls
* back to first-match.
*/
function filterByText(candidates, lines, text) {
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
// Too short to disambiguate. Return [] so the caller's `filtered.length
// === 0` branch fires (fall back to first-match) — the previous
// `candidates.slice()` return forced `filtered.length > 1` and surfaced
// a spurious `element_ambiguous` error on every short-text picker event
// with multiple candidates.
if (trimmed.length < 8) return [];
const targetSpaced = trimmed;
const targetCompact = trimmed.replace(/\s+/g, '');
return candidates.filter((c) => {
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
const inner = body
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
.toLowerCase();
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
const sourceCompact = inner.replace(/\s+/g, '');
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
});
}
/**
* Resolve a match line to the real tag opener. If the match line itself opens
* a tag, return it. Otherwise walk up to 10 lines backward looking for the
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Vite 8 + TSX repeated branches Fixture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,22 @@
{
"name": "vite8-react-tsx-repeated-aside-fixture",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.0",
"typescript": "^5.6.0",
"vite": "^8.0.0"
}
}
@@ -0,0 +1,18 @@
export default function App() {
return (
<main className="page">
<aside data-testid="card-1" className="card">
<h1 className="hero-title">Hero One</h1>
<p className="hero-hook">First card body copy.</p>
</aside>
<aside data-testid="card-2" className="card">
<h1 className="hero-title">Hero Two</h1>
<p className="hero-hook">Second card body copy.</p>
</aside>
<aside data-testid="card-3" className="card">
<h1 className="hero-title">Hero Three</h1>
<p className="hero-hook">Third card body copy.</p>
</aside>
</main>
);
}
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
import './styles.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,5 @@
body { margin: 0; font-family: system-ui, sans-serif; }
.page { padding: 2rem; display: grid; gap: 1rem; }
.card { padding: 1rem; border: 1px solid #ddd; border-radius: 0.5rem; }
.hero-title { font-size: 1.5rem; margin: 0 0 0.5rem; }
.hero-hook { color: #555; margin: 0; }
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"noEmit": true
},
"include": ["src"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { host: '127.0.0.1', strictPort: false },
});
@@ -0,0 +1,30 @@
{
"name": "Vite 8 + React TSX with repeated <aside> branches",
"config": {
"files": ["index.html"],
"insertBefore": "</body>",
"commentSyntax": "html"
},
"sourceFiles": ["index.html", "src/App.tsx", "src/main.tsx", "src/styles.css", "vite.config.ts", "tsconfig.json"],
"generatedFiles": [],
"wrapCases": [
{
"name": "wraps the picked aside (second branch) inside a Fragment so TSX stays valid",
"args": { "classes": "hero-title", "tag": "h1" },
"expectedFile": "src/App.tsx"
}
],
"runtime": {
"styling": "plain-css",
"install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"],
"devCommand": ["npx", "vite", "--host", "127.0.0.1"],
"readyPattern": "Local:\\s+https?://[^:]+:(\\d+)",
"readyTimeoutMs": 120000,
"pickSelector": "[data-testid='card-2'] h1.hero-title",
"assertSourceContains": ["Hero One", "Hero Three", "First card body copy.", "Third card body copy."],
"probe": {
"expectLiveInit": true,
"expectConsoleClean": true
}
}
}
@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
package-lock.json
+212 -1
View File
@@ -9,7 +9,7 @@ import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ACCEPT = resolve(__dirname, '..', 'source/skills/impeccable/scripts/live-accept.mjs');
@@ -123,6 +123,217 @@ describe('live-accept — style-element edge cases', () => {
assert.ok(after.includes('variant one'), 'variant 1 content kept');
});
// Regression: the agent writes JSX <style>{`…`}</style> 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 (`<style>{`{`@scope…`}`}`)
// that oxc rejects with "Expected `}` but found `@`". extractCss must
// strip the JSX wrap regardless of where the agent placed it.
it('carbonize does not double-wrap when the variants block uses JSX template literals on their own lines', () => {
const tsx = `export default function App() {\n` +
` return (\n` +
` <main>\n` +
` <>\n` +
` {/* impeccable-variants-start TPL */}\n` +
` <div data-impeccable-variants="TPL" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
` <style data-impeccable-css="TPL">\n` +
" {`\n" +
` @scope ([data-impeccable-variant="1"]) { .hook { color: red; } }\n` +
` @scope ([data-impeccable-variant="2"]) { .hook { color: green; } }\n` +
" `}\n" +
` </style>\n` +
` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
` </div>\n` +
` {/* impeccable-variants-end TPL */}\n` +
` </>\n` +
` </main>\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 <style ...> tag — not two.
const carbonStyleMatch = after.match(/<style data-impeccable-css="TPL">([\s\S]*?)<\/style>/);
assert.ok(carbonStyleMatch, 'carbonize <style> block present');
const inner = carbonStyleMatch[1];
// Inner must open with one `{` ... and end with one ` `` ... — no nesting.
const openCount = (inner.match(/\{`/g) || []).length;
const closeCount = (inner.match(/`\}/g) || []).length;
assert.equal(openCount, 1, `expected exactly one {\` opener, got ${openCount}`);
assert.equal(closeCount, 1, `expected exactly one \`} closer, got ${closeCount}`);
// CSS content survived intact.
assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
});
// Same shape, but the agent put `{`` and ``\`}` attached to first/last CSS
// lines instead of on dedicated lines. Tests the inline-strip branch.
it('carbonize does not double-wrap when JSX template-literal punctuation hugs the CSS lines', () => {
const tsx = `export default function App() {\n` +
` return (\n` +
` <main>\n` +
` <>\n` +
` {/* impeccable-variants-start INLINE */}\n` +
` <div data-impeccable-variants="INLINE" data-impeccable-variant-count="2" style={{ display: 'contents' }}>\n` +
` <div data-impeccable-variant="original"><p className="hook">orig</p></div>\n` +
` <style data-impeccable-css="INLINE">\n` +
" {`@scope ([data-impeccable-variant=\"1\"]) { .hook { color: red; } }\n" +
" @scope ([data-impeccable-variant=\"2\"]) { .hook { color: green; } }`}\n" +
` </style>\n` +
` <div data-impeccable-variant="1"><p className="hook">variant one</p></div>\n` +
` <div data-impeccable-variant="2" style={{ display: 'none' }}><p className="hook">variant two</p></div>\n` +
` </div>\n` +
` {/* impeccable-variants-end INLINE */}\n` +
` </>\n` +
` </main>\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(/<style data-impeccable-css="INLINE">([\s\S]*?)<\/style>/)[1];
const openCount = (inner.match(/\{`/g) || []).length;
const closeCount = (inner.match(/`\}/g) || []).length;
assert.equal(openCount, 1, `expected one {\` opener, got ${openCount}`);
assert.equal(closeCount, 1, `expected one \`} closer, got ${closeCount}`);
assert.ok(inner.includes('@scope ([data-impeccable-variant="1"])'), 'variant-1 scope kept');
});
// Cursor Bugbot regression (PR #118 review): the JSX wrapper places
// marker comments INSIDE the outer <div>, so block.start sits 2 spaces
// deeper than the original element. Using block.start as the deindent
// base on JSX accept/discard pushes every restored line 2 spaces too far
// right. The fix anchors the indent on `replaceRange.start` (the outer
// wrapper line), which is at the original element's indent level for
// both HTML and JSX.
it('discard restores JSX content at the original indent (no 2-space drift from marker-inside layout)', () => {
// Run the real wrap CLI so we exercise the JSX-marker-inside-wrapper
// layout end to end, not a hand-rolled approximation.
const tsx = `export default function App() {
return (
<main>
<aside className="card">
<h1 className="hero-title">Hero</h1>
</aside>
</main>
);
}`;
writeFileSync(join(tmp, 'App.tsx'), tsx);
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id INDENTDISC --count 3 --classes "card" --tag "aside" --file "${join(tmp, 'App.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
runAccept(tmp, ['--id', 'INDENTDISC', '--discard']);
const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
// The aside opener should land at exactly 6 spaces — same as the
// original — and the <h1> child at 8 (preserved relative depth).
// The earlier 6/6/6 collapse was caused by `originalLines.map(l =>
// indent + ' ' + l.trimStart())` in live-wrap stripping ALL
// leading whitespace before reindenting; the fix strips only the
// COMMON minimum so the relative structure is preserved.
assert.match(after, /^ <aside className="card">$/m,
`<aside> opener must be at 6-space indent (was 8 before outer-indent fix), got:\n${after}`);
assert.match(after, /^ <h1 className="hero-title">Hero<\/h1>$/m,
`<h1> child must be at 8-space indent — relative depth preserved through wrap+discard. Got:\n${after}`);
assert.match(after, /^ <\/aside>$/m,
`</aside> closer must be back at 6-space indent. Got:\n${after}`);
});
it('expandReplaceRange handles multi-line self-closing <div /> inside the wrapped element', () => {
// Cursor Bugbot regression: per-line depth tracking in
// `expandReplaceRange` couldn't see across line boundaries, so a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` got
// counted as +1 with no compensating -1. The wrapper's outer </div>
// never matched the depth-zero condition; replace-range stopped at
// block.end (the marker comment), leaving the wrapper's outer </div>
// orphaned in the file after accept/discard — and worse, an
// unrelated <div className="next-card"> right after the wrapper got
// its own </div> mis-counted as the wrapper close.
const tsx = `export default function App() {
return (
<main>
<aside className="card">
<h1>Hi</h1>
<div
className="spacer"
/>
<p>Body</p>
</aside>
<div className="next-card">After</div>
</main>
);
}`;
writeFileSync(join(tmp, 'App.tsx'), tsx);
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id MULTILINESC --count 3 --classes "card" --tag "aside" --file "${join(tmp, 'App.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const result = runAccept(tmp, ['--id', 'MULTILINESC', '--discard']);
assert.equal(result.handled, true, `discard should succeed: ${JSON.stringify(result)}`);
const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
// The wrapper scaffold must be fully gone — no orphan </div> from
// the outer wrapper, and no impeccable markers/data attributes.
assert.ok(!after.includes('data-impeccable-variants'),
`outer wrapper div must be fully removed; got:\n${after}`);
assert.ok(!after.includes('data-impeccable-variant'),
`original-div wrapper must be fully removed; got:\n${after}`);
assert.ok(!after.includes('impeccable-variants-start'),
`start marker must be removed; got:\n${after}`);
// The unrelated <div className="next-card">After</div> sibling
// must survive intact — Bugbot's worst-case scenario was the depth
// walk eating its </div> as the wrapper close.
assert.ok(after.includes('<div className="next-card">After</div>'),
`unrelated next-card sibling must be preserved; got:\n${after}`);
// The multi-line self-closing div inside the original element must
// survive too.
assert.match(after, /<div\s*\n\s*className="spacer"\s*\n\s*\/>/m,
`multi-line self-closing <div /> inside original must survive; got:\n${after}`);
});
it('accept (no carbonize, raw HTML) restores at the original indent on JSX', () => {
// Manually craft a wrapped file in the JSX-marker-inside layout — this
// mirrors what wrap produces, but lets us exercise accept's indent
// logic without a full live cycle.
const tsx = `export default function App() {
return (
<main>
<div data-impeccable-variants="INDENTACC" data-impeccable-variant-count="3" style={{ display: "contents" }}>
{/* impeccable-variants-start INDENTACC */}
{/* Original */}
<div data-impeccable-variant="original">
<aside className="card">
<h1 className="hero-title">Hero</h1>
</aside>
</div>
{/* Variants: insert below this line */}
<div data-impeccable-variant="1"><aside className="card variant-one"><h1 className="hero-title">Hero</h1></aside></div>
{/* impeccable-variants-end INDENTACC */}
</div>
</main>
);
}`;
writeFileSync(join(tmp, 'App.tsx'), tsx);
runAccept(tmp, ['--id', 'INDENTACC', '--variant', '1']);
const after = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
// The accepted aside (variant-one) should land at 6-space indent, the
// same place the wrapper <div> sat — not 2 spaces deeper.
assert.match(after, /^ <aside className="card variant-one">/m,
`accepted <aside> must land at 6-space indent (the wrapper's level), got:\n${after}`);
});
// Discard must restore the original element after a self-closing <style />,
// proving extractOriginal also survives the style pattern.
it('discard restores the original element after a JSX self-closing <style />', () => {
+58
View File
@@ -0,0 +1,58 @@
/**
* Static-source regression guards for live-browser.js.
*
* `source/skills/impeccable/scripts/live-browser.js` is a self-contained
* IIFE served directly to user pages by live-server.mjs (no bundle step,
* no module exports). That makes its internal helpers untestable via
* normal import but a few behaviors have failed in real-world live
* sessions in ways that are easy to express as "this exact code shape
* MUST NOT come back." This file pins those down.
*
* Add a guard whenever a bug we fix has a one-line "anti-pattern" cause
* that's easy to reintroduce on an unrelated edit.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LIVE_BROWSER = path.resolve(
__dirname,
'..',
'source/skills/impeccable/scripts/live-browser.js',
);
const SOURCE = fs.readFileSync(LIVE_BROWSER, 'utf-8');
describe('live-browser.js regression guards', () => {
it('resolveCanvasBackground does not fall back to `getComputedStyle(...).backgroundColor || ...`', () => {
// The browser returns the literal string `"rgba(0, 0, 0, 0)"` for an
// unset body/html background. That string is non-empty and truthy, so a
// `||` chain short-circuits to transparent-black, which modern-screenshot
// hands to its WebGL shader as the canvas color and the screenshot
// overlay flashes solid black during loading on any page that doesn't
// explicitly set its own background. Forbid the pattern outright; the
// correct fallback is a literal `'#ffffff'` (the browser's default
// canvas color).
const buggy =
/getComputedStyle\(document\.(?:body|documentElement)\)\.backgroundColor\s*\|\|/;
assert.ok(
!buggy.test(SOURCE),
'live-browser.js must not chain `getComputedStyle(...).backgroundColor || ...` — that returns transparent-black for default-bg pages and renders the screenshot overlay as solid black during loading. Use a literal fallback (`#ffffff`) instead.',
);
});
it('detectPageTheme honors alpha when reading body / html backgroundColor', () => {
// Equivalent trap: `rgba(0, 0, 0, 0)` parsed naively as `(0,0,0)` makes
// a perfectly white default page register as "dark," which flips the
// chrome to the wrong palette. The fix introduced an alpha guard
// (function readOpaque) — keep that signature in source.
assert.match(
SOURCE,
/function detectPageTheme\b[\s\S]{0,1500}?function readOpaque\b/,
'detectPageTheme must keep its readOpaque helper that filters out fully-transparent backgrounds before computing luminance',
);
});
});
+13
View File
@@ -269,6 +269,19 @@ for (const { name, fixture } of fixtures) {
'accepted h1 survives with hero-title class',
);
// Optional fixture hook: assert that arbitrary strings survive the
// wrap → accept → carbonize cycle. Used by repeated-branch fixtures
// to prove wrap disambiguated correctly — sibling branches the test
// didn't pick should be untouched.
if (Array.isArray(fixture.runtime.assertSourceContains)) {
for (const needle of fixture.runtime.assertSourceContains) {
assert.ok(
final.includes(needle),
`source still contains ${JSON.stringify(needle)} after accept (sibling branch must not be rewritten)`,
);
}
}
// 9. DOM-side: at least one matching element, none inside any wrapper.
await page.waitForFunction(
(sel) => {
+7 -1
View File
@@ -323,12 +323,17 @@ export async function runAgentLoop({
// sessions: the agent must derive the selector from the picked
// element on the fly).
const target = typeof wrapTarget === 'function' ? wrapTarget(event) : wrapTarget;
// Pull textContent from the picker event so wrap can disambiguate
// when sibling elements share classes/tag (issue #114). Fixtures can
// still override by including `text` in their wrapTarget.
const text = target.text ?? (event.element?.textContent || '').trim();
const wrapInfo = await runWrap({
tmp,
scriptsDir,
id: event.id,
count: event.count,
...target,
text,
});
log(`wrapped: ${wrapInfo.file} insertLine=${wrapInfo.insertLine}`);
@@ -426,11 +431,12 @@ export async function runAgentLoop({
}
}
async function runWrap({ tmp, scriptsDir, id, count, classes, tag, elementId }) {
async function runWrap({ tmp, scriptsDir, id, count, classes, tag, elementId, text }) {
const args = [path.join(scriptsDir, 'live-wrap.mjs'), '--id', id, '--count', String(count)];
if (elementId) args.push('--element-id', elementId);
if (classes) args.push('--classes', classes);
if (tag) args.push('--tag', tag);
if (text) args.push('--text', text);
const { stdout } = await execFileP(process.execPath, args, { cwd: tmp });
const last = stdout.trim().split('\n').filter(Boolean).pop();
return JSON.parse(last);
+65
View File
@@ -128,6 +128,71 @@ describe('live-inject — insert/remove round-trip preserves file bytes', () =>
}
});
it('round-trips with insertAfter — preserves indented opener line below it', () => {
const original = `<!DOCTYPE html>
<html>
<head>
<title>Test</title>
</head>
<body>
<main>
<h1>Hello</h1>
</main>
</body>
</html>
`;
const file = join(tmp, 'index.html');
writeFileSync(file, original);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['index.html'],
insertAfter: '<head>',
commentSyntax: 'html',
}));
runInject(tmp, cfgPath, ['--port', '8400']);
runInject(tmp, cfgPath, ['--remove']);
const after = readFileSync(file, 'utf-8');
assert.equal(after, original, 'insertAfter round-trip must restore original byte-for-byte');
});
it('round-trips through CSP-meta patch and revert (insert mutates the meta tag, remove restores it)', () => {
// Mirrors a Vite app that ships a CSP meta tag in index.html. live-inject
// appends `http://localhost:PORT` to script-src / connect-src on insert
// and stashes the original directives in `data-impeccable-csp-original`.
// --remove must restore the meta tag's original `content` exactly.
const original = `<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; connect-src 'self';" />
<title>CSP test</title>
</head>
<body>
<main>
<h1>Hello</h1>
</main>
</body>
</html>
`;
const file = join(tmp, 'index.html');
writeFileSync(file, original);
const cfgPath = join(tmp, 'config.json');
writeFileSync(cfgPath, JSON.stringify({
files: ['index.html'],
insertBefore: '</body>',
commentSyntax: 'html',
}));
runInject(tmp, cfgPath, ['--port', '8400']);
runInject(tmp, cfgPath, ['--remove']);
const after = readFileSync(file, 'utf-8');
assert.equal(after, original, 'CSP meta tag must round-trip exactly through insert+remove');
});
it('round-trips when the insert anchor has no leading indent (column-0 </body>)', () => {
const original = `<html>
<body>
+265
View File
@@ -441,6 +441,271 @@ describe('live-wrap — JSX / TSX correctness', () => {
assert.ok(!inside.includes('extra-class'), 'decoy not wrapped');
});
it('keeps the JSX wrapper single-rooted by tucking marker comments INSIDE the outer <div>', () => {
// Replacing one JSX element with [comment, <div>, comment] yields three
// adjacent siblings, which Vite's oxc rejects with "Adjacent JSX
// elements must be wrapped in an enclosing tag." A Fragment `<></>`
// would solve adjacency but breaks `cloneElement`-using parents (Radix
// `asChild` etc.) with "Invalid prop supplied to React.Fragment". The
// wrap script's answer is to tuck the markers INSIDE the outer wrapper
// <div>, which IS the single JSX-slot child.
const tsx = `export default function App() {
return (
<main>
<section className="frag-target">
<h1>Hi</h1>
</section>
</main>
);
}`;
writeFileSync(join(tmp, 'App.tsx'), tsx);
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id frag1 --count 3 --classes "frag-target" --tag "section" --file "${join(tmp, 'App.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'App.tsx'), 'utf-8');
// No JSX Fragment wrappers (those break asChild/cloneElement parents).
assert.ok(!modified.includes('<>'), 'no Fragment opener emitted');
assert.ok(!modified.includes('</>'), 'no Fragment closer emitted');
// The outer wrapper <div data-impeccable-variants="..."> appears BEFORE
// both marker comments — markers are tucked inside.
const wrapperIdx = modified.indexOf('data-impeccable-variants="frag1"');
const startMarkerIdx = modified.indexOf('impeccable-variants-start frag1');
const endMarkerIdx = modified.indexOf('impeccable-variants-end frag1');
assert.ok(wrapperIdx !== -1 && startMarkerIdx !== -1 && endMarkerIdx !== -1, 'all markers present');
assert.ok(wrapperIdx < startMarkerIdx, 'wrapper opens before start-marker comment');
assert.ok(endMarkerIdx > startMarkerIdx, 'end marker follows start marker');
});
it('HTML wrapper keeps marker comments OUTSIDE the wrapper <div> (existing layout)', () => {
const html = '<main>\n <section class="html-frag">Hi</section>\n</main>';
writeFileSync(join(tmp, 'page.html'), html);
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id htmlFrag --count 3 --classes "html-frag" --tag "section" --file "${join(tmp, 'page.html')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'page.html'), 'utf-8');
const wrapperIdx = modified.indexOf('data-impeccable-variants="htmlFrag"');
const startMarkerIdx = modified.indexOf('impeccable-variants-start htmlFrag');
assert.ok(startMarkerIdx < wrapperIdx, 'HTML start marker precedes wrapper div');
});
it('disambiguates repeated JSX siblings via --text and lands on the correct branch', () => {
// Three <aside className="card"> elements with identical classes/tag —
// the user picked the SECOND one. Without --text, first-match wraps the
// first. With --text matching the picked element's textContent, wrap
// narrows to the right branch.
const tsx = `export default function Page() {
return (
<main>
<aside className="card">
<h2>Alpha card</h2>
<p>First in the list.</p>
</aside>
<aside className="card">
<h2>Beta card</h2>
<p>Second in the list.</p>
</aside>
<aside className="card">
<h2>Gamma card</h2>
<p>Third in the list.</p>
</aside>
</main>
);
}`;
writeFileSync(join(tmp, 'Page.tsx'), tsx);
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id repeat1 --count 3 --classes "card" --tag "aside" --text "Beta card Second in the list." --file "${join(tmp, 'Page.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
const originalMatch = modified.match(/data-impeccable-variant="original"[\s\S]*?<\/div>/);
assert.ok(originalMatch, 'original wrapper present');
const inside = originalMatch[0];
assert.ok(inside.includes('Beta card'), 'wrapped the Beta card (the picked one)');
assert.ok(!inside.includes('Alpha card'), 'did not wrap Alpha');
assert.ok(!inside.includes('Gamma card'), 'did not wrap Gamma');
});
it('disambiguates when the picked element has multiple text-node children (textContent has no inter-element whitespace)', () => {
// Real-world regression caught while driving a live loop in the browser.
// textContent concatenates child text without inserting whitespace, so
// an <aside><h1>Hero Two</h1><p>Second card body copy.</p></aside> reads
// as "Hero TwoSecond card body copy." — but the source has whitespace
// between </h1> and <p>. A single-space normalization on both sides
// misses the join boundary; a no-whitespace normalization catches it.
const tsx = `export default function Page() {
return (
<main>
<aside className="card">
<h1 className="hero-title">Hero One</h1>
<p className="hero-hook">First card body copy.</p>
</aside>
<aside className="card">
<h1 className="hero-title">Hero Two</h1>
<p className="hero-hook">Second card body copy.</p>
</aside>
<aside className="card">
<h1 className="hero-title">Hero Three</h1>
<p className="hero-hook">Third card body copy.</p>
</aside>
</main>
);
}`;
writeFileSync(join(tmp, 'Page.tsx'), tsx);
// Note: --text is the textContent the BROWSER produced — no space between
// "Two" and "Second" because textContent has no inter-element whitespace.
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id concat1 --count 3 --classes "card" --tag "aside" --text "Hero TwoSecond card body copy." --file "${join(tmp, 'Page.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'Page.tsx'), 'utf-8');
const originalMatch = modified.match(/data-impeccable-variant="original"[\s\S]*?<\/div>/);
assert.ok(originalMatch, 'original wrapper present');
const inside = originalMatch[0];
assert.ok(inside.includes('Hero Two'), 'wrapped Hero Two (the picked card)');
assert.ok(!inside.includes('Hero One'), 'did not wrap Hero One');
assert.ok(!inside.includes('Hero Three'), 'did not wrap Hero Three');
});
it('short --text falls back to first-match instead of erroneously firing element_ambiguous', () => {
// Cursor Bugbot regression: filterByText returned `candidates.slice()`
// (all candidates) when the trimmed snippet was shorter than 8 chars.
// The caller treats `filtered.length > 1` as ambiguous — so a short
// textContent on a page with multiple matching siblings produced a
// spurious `element_ambiguous` error instead of just landing on the
// first match (the documented short-text fallback).
const tsx = `export default function Page() {
return (
<main>
<aside className="card"><h1 className="hero-title">Hi</h1></aside>
<aside className="card"><h1 className="hero-title">Hi</h1></aside>
</main>
);
}`;
writeFileSync(join(tmp, 'Short.tsx'), tsx);
// Picked element's textContent is 'Hi' — only 2 chars. With multiple
// matching siblings the prior bug fired element_ambiguous; the fix
// makes wrap silently land on the first match (existing behavior
// documented in filterByText's JSDoc).
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id short1 --count 3 --classes "card" --tag "aside" --text "Hi" --file "${join(tmp, 'Short.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'Short.tsx'), 'utf-8');
assert.ok(modified.includes('data-impeccable-variants="short1"'),
'short --text should still wrap (fallback to first-match), not fail with element_ambiguous');
});
it('returns endLine that includes the multi-line original content offset', () => {
// Cursor Bugbot regression: the `endLine` field was computed as
// `startLine + wrapperLines.length`, but `wrapperLines` is an array
// where one element (originalIndented) is a `\n`-joined multi-line
// string. For multi-line picked elements, the actual wrapper region
// in the file spans (wrapperLines.length + originalLines.length - 1)
// rows. Reporting too-small endLine misled agents writing variants
// about the wrapper boundary.
const html = `<main>
<section class="multiline-target">
<h1>Multi</h1>
<p>Line</p>
<span>Element</span>
</section>
</main>`;
writeFileSync(join(tmp, 'multi.html'), html);
const result = JSON.parse(execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id ml1 --count 3 --classes "multiline-target" --tag "section" --file "${join(tmp, 'multi.html')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
));
const modified = readFileSync(join(tmp, 'multi.html'), 'utf-8');
const lines = modified.split('\n');
// endLine is 1-indexed; lines[endLine - 1] should be the wrapper's last
// line (the impeccable-variants-end marker for HTML).
assert.match(lines[result.endLine - 1], /impeccable-variants-end ml1/,
`endLine ${result.endLine} should point at the variants-end marker line. Got: ${JSON.stringify(lines[result.endLine - 1])}`);
// And the line after the reported endLine should be `</main>` — proving
// the entire wrapper was accounted for (no rows missing).
assert.match(lines[result.endLine], /<\/main>/,
`line after endLine should be </main>; got: ${JSON.stringify(lines[result.endLine])}`);
});
it('falls back to first-match when --text is not literally present in source (e.g. {title})', () => {
// textContent the browser sends is the rendered text, but the source uses
// a JSX expression. No candidate's source body contains the literal
// textContent — wrap should keep the first-match behavior rather than
// refusing, because failing here would be more annoying than wrong.
const tsx = `export default function Cards({ items }) {
return (
<main>
{items.map(item => (
<aside key={item.id} className="card">
<h2>{item.title}</h2>
</aside>
))}
</main>
);
}`;
writeFileSync(join(tmp, 'Cards.tsx'), tsx);
// Run with --text that won't show up in source verbatim.
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id dyn1 --count 3 --classes "card" --tag "aside" --text "Beta card body text" --file "${join(tmp, 'Cards.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8' }
);
const modified = readFileSync(join(tmp, 'Cards.tsx'), 'utf-8');
assert.ok(modified.includes('data-impeccable-variants="dyn1"'), 'wrapped (first-match fallback)');
});
it('errors with element_ambiguous when --text matches multiple identical branches', () => {
// Two <aside className="card"> with truly identical body text. --text
// can't pick a winner — wrap should refuse rather than silently land.
const tsx = `export default function Page() {
return (
<main>
<aside className="card">
<h2>Same headline</h2>
<p>Identical body copy.</p>
</aside>
<aside className="card">
<h2>Same headline</h2>
<p>Identical body copy.</p>
</aside>
</main>
);
}`;
writeFileSync(join(tmp, 'Dup.tsx'), tsx);
let errPayload;
try {
execSync(
`node source/skills/impeccable/scripts/live-wrap.mjs --id dup1 --count 3 --classes "card" --tag "aside" --text "Same headline Identical body copy." --file "${join(tmp, 'Dup.tsx')}"`,
{ cwd: process.cwd(), encoding: 'utf-8', stdio: 'pipe' }
);
assert.fail('Should have exited with error');
} catch (err) {
assert.ok(err.status !== 0, 'non-zero exit');
errPayload = JSON.parse(err.stderr.toString().trim());
}
assert.equal(errPayload.error, 'element_ambiguous');
assert.equal(errPayload.fallback, 'agent-driven');
assert.ok(Array.isArray(errPayload.candidates) && errPayload.candidates.length === 2,
'two candidate locations reported');
});
it('respects --tag to reject matches inside the wrong element type', () => {
// Two elements, both containing the class. The <div> comes first in source
// order; a tag-agnostic search would wrap it. With --tag section, the