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:
Paul Bakaus
2026-04-28 15:03:49 -07:00
co-authored by Claude Opus 4.7
parent 54d9f05ea5
commit 9ec904302b
27 changed files with 485 additions and 195 deletions
+43
View File
@@ -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