mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
54d9f05ea5
commit
9ec904302b
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@ 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
|
||||
@@ -266,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.
|
||||
|
||||
@@ -438,28 +438,35 @@ function findAllElements(lines, query, tag = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a candidate set to those whose source body literally contains a
|
||||
* meaningful prefix of the picked element's textContent. The compare ignores
|
||||
* tags, JSX expressions (`{title}`), and whitespace so a match survives JSX
|
||||
* formatting variation. A snippet shorter than 6 chars after stripping is
|
||||
* treated as too weak to disambiguate — the caller should fall back.
|
||||
* 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 target = text
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 80);
|
||||
if (target.length < 6) return candidates.slice();
|
||||
const trimmed = text.replace(/\s+/g, ' ').trim().toLowerCase().slice(0, 80);
|
||||
if (trimmed.length < 8) return candidates.slice();
|
||||
const targetSpaced = trimmed;
|
||||
const targetCompact = trimmed.replace(/\s+/g, '');
|
||||
|
||||
return candidates.filter((c) => {
|
||||
const body = lines.slice(c.startLine, c.endLine + 1).join(' ');
|
||||
const norm = body
|
||||
const inner = body
|
||||
.replace(/<[^>]*>/g, ' ') // strip HTML/JSX tags
|
||||
.replace(/\{[^}]*\}/g, ' ') // strip JSX expressions
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return norm.includes(target);
|
||||
const sourceSpaced = inner.replace(/\s+/g, ' ').trim();
|
||||
const sourceCompact = inner.replace(/\s+/g, '');
|
||||
return sourceSpaced.includes(targetSpaced) || sourceCompact.includes(targetCompact);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -534,6 +534,49 @@ describe('live-wrap — JSX / TSX correctness', () => {
|
||||
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('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
|
||||
|
||||
Reference in New Issue
Block a user