fix(studio-server): child-scoped patch operations with batch abort (#1908)

* test(studio): add design-panel QA fixture and triage matrix

Fixture project covering all panel-editable element archetypes,
plus the QA findings matrix from the design-panel bug campaign.

* fix(studio): make canvas selection hit intended elements

- honor author pointer-events:none in hit-testing (was selecting invisible overlays)
- pause playback before mousedown sampling; fall back to hover selection on null resolve
- invalidate committed selection when the active composition changes
- double-click keeps selection and defers to multi-candidate click cycling

* fix(studio): close remaining selection-layer review findings

- hoverSelection fallback now wired at all 3 mousedown call sites (box-click,
  blocked-drag, plain overlay click) instead of just the overlay path
- pointer-events override detection reads computed style, not inline style,
  so a CSS-class opt-in (not just inline style=) on a descendant is honored
- defensively remove the pointer-events override before the group-fallback
  check too, closing a theoretical gap in the no-elementsFromPoint branch
- a click that resolves to nothing (dead-zone / deselect) no longer leaves
  playback paused if it was already playing

* fix(studio-server): child-scoped patch operations with batch abort

- PatchOperation gains optional childSelector/childIndex resolved under the matched parent
- pre-pass resolves every op target; any miss aborts the batch with matched:false, no partial write
- style-decl parsing extracted to sourceStyleMutation to stay under the file-size cap
- new ./source-mutation subpath export (mirrors ./finite-mutation)
This commit is contained in:
Miguel Ángel
2026-07-03 17:53:17 -07:00
committed by GitHub
parent 02e9d6142d
commit 23f67da5e2
6 changed files with 385 additions and 299 deletions
@@ -3,10 +3,7 @@ import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
patchElementInHtml,
splitElementInHtml,
probeElementInSource,
wrapElementsInHtml,
unwrapElementsFromHtml,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -130,6 +127,69 @@ describe("patchElementInHtml", () => {
expect(result).not.toContain("Hello World");
});
it("applies child-scoped inline style without changing the parent style", () => {
const source = `<div data-hf-id="parent" style="color: red"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const parent = document.querySelector('[data-hf-id="parent"]');
const children = Array.from(document.querySelectorAll(".line"));
expect(parent?.getAttribute("style")).toContain("color: red");
expect(children[0]?.getAttribute("style")).toBeNull();
expect(children[1]?.getAttribute("style")).toContain("color: blue");
});
it("applies child-scoped text content to the child only", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const { html: result, matched } = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "text-content",
property: "text",
value: "B < C & D",
childSelector: ":scope > span",
childIndex: 1,
},
]);
expect(matched).toBe(true);
const { document } = parseHTML(result);
const children = Array.from(document.querySelectorAll(".line"));
expect(children[0]?.textContent).toBe("A");
expect(children[1]?.textContent).toBe("B < C & D");
});
it("rejects the whole batch when a child-scoped operation cannot resolve", () => {
const source = `<div data-hf-id="parent"><span class="line">A</span><span class="line">B</span></div>`;
const result = patchElementInHtml(source, { hfId: "parent" }, [
{
type: "inline-style",
property: "color",
value: "blue",
childSelector: ":scope > span",
childIndex: 0,
},
{
type: "text-content",
property: "text",
value: "missing",
childSelector: ":scope > strong",
childIndex: 0,
},
]);
expect(result.matched).toBe(false);
expect(result.html).toBe(source);
});
it("applies multiple operations in one call", () => {
const { html: result } = patchElementInHtml(FIXTURE, { id: "hero" }, [
{ type: "inline-style", property: "color", value: "blue" },
@@ -459,230 +519,3 @@ describe("T7 — data-hf-id targeting (spec for R1)", () => {
expect(html).toContain('data-hf-id="hf-a1b2"');
});
});
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
// #title has no data-start/data-duration (GSAP-driven); the store supplies the range.
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
// original windowed to [0, 2], clone to [2, 4] (attribute order is serializer-defined)
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/)![0];
expect(original).toContain('data-start="0"');
expect(original).toContain('data-duration="2"');
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/)![0];
expect(clone).toContain('data-start="2"');
expect(clone).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
// Three positioning flavours the rebase must leave visually identical:
// plain inline left/top, a GSAP transform delta, and a --hf-studio-offset var.
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
</div></body></html>`;
// bbox top-left = (min left, min top) over the three members.
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 }, // 260-260, 100-50
{ target: { id: "logo" }, left: 40, top: 150 }, // 300-260, 200-50
{ target: { id: "badge" }, left: 140, top: 0 }, // 400-260, 50-50
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = document.querySelector('[data-hf-group="Group 1"]')!;
expect(group).not.toBeNull();
// Wrapper sits at the bbox top-left.
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
// Members are inside the wrapper, in original DOM order (= z-order).
const childIds = Array.from(group.children).map((c) => c.id);
expect(childIds).toEqual(["title", "logo", "badge"]);
// Non-member stays outside.
expect(document.querySelector("#outside")!.parentElement).toBe(
document.querySelector('[data-composition-id="main"]'),
);
// Each member rebased; transform + offset var untouched.
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 0, top: 50 });
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 40, top: 150 });
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 140, top: 0 });
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = document.querySelector('[data-composition-id="main"]')!;
// Members back in the parent, original order relative to the outside sibling.
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
// Coordinates restored; transform + offset var intact.
expect(leftTop(document.querySelector("#title")!)).toEqual({ left: 260, top: 100 });
expect(leftTop(document.querySelector("#logo")!)).toEqual({ left: 300, top: 200 });
expect(document.querySelector("#logo")!.getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(document.querySelector("#badge")!)).toEqual({ left: 400, top: 50 });
expect(document.querySelector("#badge")!.getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("rejects members that do not share a single parent", () => {
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
// [low, middle (non-member), high]; group {low, high}. The group adopts the
// topmost member's stacking, so `middle` ends up BELOW the wrapper (not hoisted
// above it), and the wrapper carries the max member z-index.
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = document.querySelector('[data-composition-id="main"]')!;
const group = document.querySelector('[data-hf-group="Group 1"]')!;
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
// Non-member sits BEFORE (below) the group, not after (above) it.
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
// Wrapper adopts the topmost member's z-index (max of 2 and 4).
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});