diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index 75d0064561..f9714b15a6 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -36,6 +36,9 @@ "packages/producer/src/services/__fixtures__/crashOnMessageWorker.mjs", "scripts/*.{ts,mjs,js}", "scripts/*/run.mjs", + // Standalone agent-browser e2e smoke, run by hand per its header — no + // import-graph referrer. + "packages/studio/tests/e2e/design-panel.mjs", // Keyframe UI components — wired dynamically via EaseCurveSection/MotionPanel. "packages/studio/src/components/editor/KeyframeDiamond.tsx", "packages/studio/src/components/editor/SpringEaseEditor.tsx", @@ -263,6 +266,10 @@ // require intrusive middleware changes beyond this PR's scope. "minLines": 6, "ignore": [ + // sourcePatcher.ts: pre-existing internal clones between the inline-style + // and attribute tag-patchers; only the PatchOperation type gained two + // optional fields here, but the line shift makes fallow re-flag them. + "packages/studio/src/utils/sourcePatcher.ts", // gsapParser.ts: recast/babel GSAP writer — intentional duplication between // recast and acorn parallel implementations (pre-existing, moved from core). "packages/parsers/src/gsapParser.ts", @@ -363,6 +370,11 @@ // complexity pre-dates the computed-timeline work. Exempted at file level // rather than refactored as scope creep. "ignore": [ + // sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations / + // patch*InTag pre-date this PR; only the PatchOperation type gained two + // optional fields, but the line-shift fingerprint re-flags the inherited + // complexity. + "packages/studio/src/utils/sourcePatcher.ts", // timeline.ts: collectRuntimeTimelinePayload (CRITICAL) pre-dates this PR; // only an import line changed here (slideshow/sceneId → slideshow/index), // but the line-shift fingerprint makes fallow re-flag inherited complexity. diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 010158ec73..6c1a5e3b9e 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -50,6 +50,12 @@ "node": "./dist/helpers/finiteMutation.js", "import": "./src/helpers/finiteMutation.ts", "types": "./src/helpers/finiteMutation.ts" + }, + "./source-mutation": { + "bun": "./src/helpers/sourceMutation.ts", + "node": "./dist/helpers/sourceMutation.js", + "import": "./src/helpers/sourceMutation.ts", + "types": "./src/helpers/sourceMutation.ts" } }, "publishConfig": { @@ -79,6 +85,10 @@ "./finite-mutation": { "import": "./dist/helpers/finiteMutation.js", "types": "./dist/helpers/finiteMutation.d.ts" + }, + "./source-mutation": { + "import": "./dist/helpers/sourceMutation.js", + "types": "./dist/helpers/sourceMutation.d.ts" } }, "main": "./dist/index.js", diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index fd28dca529..4f7f945ff3 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -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 = `
AB
`; + 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 = `
AB
`; + 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 = `
AB
`; + 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 = `
`; - 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 = `
Hello
`; - - 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( - "", - '
Existing
', - ); - 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 = `

Hi

`; - 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(/]*\bid="title"[^>]*>/)![0]; - expect(original).toContain('data-start="0"'); - expect(original).toContain('data-duration="2"'); - const clone = result.html.match(/]*\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 = `

Hi

`; - 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 = `
-
Title
- -
Badge
-
Outside
-
`; - - // 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 = `
`; - 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 = `
`; - 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 = `
`; - const result = unwrapElementsFromHtml(html, { id: "plain" }); - expect(result.unwrapped).toBe(false); - expect(result.html).toBe(html); - }); -}); diff --git a/packages/studio-server/src/helpers/sourceMutation.ts b/packages/studio-server/src/helpers/sourceMutation.ts index 62cbcc62d8..f188dac4f0 100644 --- a/packages/studio-server/src/helpers/sourceMutation.ts +++ b/packages/studio-server/src/helpers/sourceMutation.ts @@ -2,6 +2,7 @@ import { parseHTML } from "linkedom"; import postcss from "postcss"; import selectorParser from "postcss-selector-parser"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "@hyperframes/core/html-attr-safety"; +import { parseStyleDecls, patchStyleAttrString } from "./sourceStyleMutation.js"; export interface SourceMutationTarget { id?: string | null; @@ -123,75 +124,32 @@ export function removeElementFromHtml(source: string, target: SourceMutationTarg return wrappedFragment ? document.body.innerHTML || "" : document.toString(); } -export function isHTMLElement(el: Element): el is HTMLElement { - const HTMLEl = el.ownerDocument.defaultView?.HTMLElement; - return HTMLEl ? el instanceof HTMLEl : "style" in el; +export function isHTMLElement(el: Node): el is HTMLElement { + const HTMLEl = el.ownerDocument?.defaultView?.HTMLElement; + return HTMLEl ? el instanceof HTMLEl : el.nodeType === 1 && "style" in el; } export interface PatchOperation { type: "inline-style" | "attribute" | "html-attribute" | "text-content"; property: string; value: string | null; + childSelector?: string; + childIndex?: number; } -// fallow-ignore-next-line complexity -function parseStyleDecls(style: string): { props: Map; order: string[] } { - const props = new Map(); - const order: string[] = []; - // Tokenize declarations robustly: values can contain ';' inside quoted strings - // (e.g. content: ';') and ':' inside values (data URIs, url(), etc.). - // Split on ';' only when outside quotes and balanced parens; the first ':' in - // the resulting segment is the property/value separator (property names never - // contain ':'). - let i = 0; - while (i < style.length) { - let depth = 0; - let inSingle = false; - let inDouble = false; - const start = i; - while (i < style.length) { - const ch = style[i]; - if (ch === "'" && !inDouble) inSingle = !inSingle; - else if (ch === '"' && !inSingle) inDouble = !inDouble; - else if (!inSingle && !inDouble) { - if (ch === "(") depth++; - else if (ch === ")") depth = Math.max(0, depth - 1); - else if (ch === ";" && depth === 0) break; - } - i++; - } - const decl = style.slice(start, i).trim(); - i++; // advance past ';' - if (!decl) continue; - const colon = decl.indexOf(":"); - if (colon < 0) continue; - const key = decl.slice(0, colon).trim(); - const val = decl.slice(colon + 1).trim(); - if (!key) continue; - if (!props.has(key)) order.push(key); - props.set(key, val); - } - return { props, order }; -} - -function serializeStyleDecls(props: Map, order: string[]): string { - return order - .map((k) => `${k}: ${props.get(k) ?? ""}`) - .filter((d) => d.trim()) - .join("; "); +interface ResolvedPatchOperation { + op: PatchOperation; + target: HTMLElement; } -function patchStyleAttrString(style: string, property: string, value: string | null): string { - const { props, order } = parseStyleDecls(style); - if (value === null) { - props.delete(property); - const idx = order.indexOf(property); - if (idx >= 0) order.splice(idx, 1); - } else { - if (!props.has(property)) order.push(property); - props.set(property, value); +function resolveOperationTarget(parent: HTMLElement, op: PatchOperation): HTMLElement | null { + if (op.childSelector === undefined) return parent; + try { + const child = parent.querySelectorAll(op.childSelector)[op.childIndex ?? 0] ?? null; + return child && isHTMLElement(child) ? child : null; + } catch { + return null; } - return serializeStyleDecls(props, order); } // fallow-ignore-next-line complexity @@ -205,7 +163,14 @@ export function patchElementInHtml( if (!el || !isHTMLElement(el)) return { html: source, matched: false }; const htmlEl = el; + const resolved: ResolvedPatchOperation[] = []; for (const op of operations) { + const opTarget = resolveOperationTarget(htmlEl, op); + if (!opTarget) return { html: source, matched: false }; + resolved.push({ op, target: opTarget }); + } + + for (const { op, target: opTarget } of resolved) { switch (op.type) { case "inline-style": // linkedom's CSSStyleDeclaration does not support CSS custom properties @@ -213,18 +178,18 @@ export function patchElementInHtml( // scale) via style.setProperty(). Manipulate the style attribute string // directly so all property names survive the round-trip. { - const raw = htmlEl.getAttribute("style") ?? ""; + const raw = opTarget.getAttribute("style") ?? ""; const patched = patchStyleAttrString(raw, op.property, op.value); - htmlEl.setAttribute("style", patched); + opTarget.setAttribute("style", patched); } break; case "attribute": { const fullAttr = op.property.startsWith("data-") ? op.property : `data-${op.property}`; if (op.value != null) { - htmlEl.setAttribute(fullAttr, op.value); + opTarget.setAttribute(fullAttr, op.value); } else { - htmlEl.removeAttribute(fullAttr); + opTarget.removeAttribute(fullAttr); } } break; @@ -232,15 +197,15 @@ export function patchElementInHtml( if (!isAllowedHtmlAttribute(op.property)) break; if (op.value != null) { if (!isSafeAttributeValue(op.property, op.value)) break; - htmlEl.setAttribute(op.property, op.value); + opTarget.setAttribute(op.property, op.value); } else { - htmlEl.removeAttribute(op.property); + opTarget.removeAttribute(op.property); } break; case "text-content": if (op.value != null) { - const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null; - const textTarget = inner && isHTMLElement(inner) ? inner : htmlEl; + const inner = opTarget.children.length === 1 ? opTarget.firstElementChild : null; + const textTarget = inner && isHTMLElement(inner) ? inner : opTarget; textTarget.textContent = op.value; } break; @@ -333,7 +298,8 @@ export function splitElementInHtml( const firstDuration = splitTime - start; const secondDuration = duration - firstDuration; - const clone = el.cloneNode(true) as HTMLElement; + const clone = el.cloneNode(true); + if (!isHTMLElement(clone)) return { html: source, matched: false, newId: null }; clone.setAttribute("id", newId); clone.removeAttribute("data-hf-id"); // Descendants carry their own data-hf-id; leaving them duplicates the id of diff --git a/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts new file mode 100644 index 0000000000..6bd4d92dcb --- /dev/null +++ b/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts @@ -0,0 +1,218 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, it } from "vitest"; +import { + splitElementInHtml, + unwrapElementsFromHtml, + wrapElementsInHtml, +} from "./sourceMutation.js"; + +describe("splitElementInHtml — hfId clone isolation", () => { + it("does not copy data-hf-id to the cloned second half", () => { + const source = `
`; + 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 = `
Hello
`; + + 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( + "", + '
Existing
', + ); + 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", () => { + const gsapSource = `

Hi

`; + const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", { + start: 0, + duration: 6, + }); + expect(result.matched).toBe(true); + const original = result.html.match(/]*\bid="title"[^>]*>/); + const clone = result.html.match(/]*\bid="title-split"[^>]*>/); + expect(original?.[0]).toContain('data-start="0"'); + expect(original?.[0]).toContain('data-duration="2"'); + expect(clone?.[0]).toContain('data-start="2"'); + expect(clone?.[0]).toContain('data-duration="4"'); + }); + + it("still rejects a no-timing element when no fallback timing is given", () => { + const gsapSource = `

Hi

`; + 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", () => { + const FIXTURE = `
+
Title
+ +
Badge
+
Outside
+
`; + + const BBOX = { left: 260, top: 50, width: 300, height: 300 }; + const REBASES = [ + { target: { id: "title" }, left: 0, top: 50 }, + { target: { id: "logo" }, left: 40, top: 150 }, + { target: { id: "badge" }, left: 140, top: 0 }, + ]; + 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 }; + } + + function requireElement(document: Document, selector: string): Element { + const element = document.querySelector(selector); + if (!element) throw new Error(`Expected ${selector} to match`); + return element; + } + + 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 = requireElement(document, '[data-hf-group="Group 1"]'); + + expect(leftTop(group)).toEqual({ left: 260, top: 50 }); + expect(Array.from(group.children).map((c) => c.id)).toEqual(["title", "logo", "badge"]); + expect(requireElement(document, "#outside").parentElement).toBe( + requireElement(document, '[data-composition-id="main"]'), + ); + expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 0, top: 50 }); + expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 40, top: 150 }); + expect(requireElement(document, "#logo").getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 140, top: 0 }); + expect(requireElement(document, "#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 = requireElement(document, '[data-composition-id="main"]'); + expect(Array.from(main.children).map((c) => c.id)).toEqual([ + "title", + "logo", + "badge", + "outside", + ]); + expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 260, top: 100 }); + expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 300, top: 200 }); + expect(requireElement(document, "#logo").getAttribute("style")).toContain( + "transform: translate(10px, 5px)", + ); + expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 400, top: 50 }); + expect(requireElement(document, "#badge").getAttribute("style")).toContain( + "--hf-studio-offset: 12px", + ); + }); + + it("rejects members that do not share a single parent", () => { + const split = `
`; + 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", () => { + const fixture = `
`; + 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 = requireElement(document, '[data-composition-id="main"]'); + const group = requireElement(document, '[data-hf-group="Group 1"]'); + expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]); + const topChildren = Array.from(parent.children).map( + (c) => c.getAttribute("data-hf-group") ?? c.id, + ); + expect(topChildren).toEqual(["middle", "Group 1"]); + expect(group.getAttribute("style")).toMatch(/z-index:\s*4/); + }); + + it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => { + const html = `
`; + const result = unwrapElementsFromHtml(html, { id: "plain" }); + expect(result.unwrapped).toBe(false); + expect(result.html).toBe(html); + }); +}); diff --git a/packages/studio-server/src/helpers/sourceStyleMutation.ts b/packages/studio-server/src/helpers/sourceStyleMutation.ts new file mode 100644 index 0000000000..fdb21371f8 --- /dev/null +++ b/packages/studio-server/src/helpers/sourceStyleMutation.ts @@ -0,0 +1,58 @@ +// fallow-ignore-next-line complexity +export function parseStyleDecls(style: string): { props: Map; order: string[] } { + const props = new Map(); + const order: string[] = []; + let i = 0; + while (i < style.length) { + let depth = 0; + let inSingle = false; + let inDouble = false; + const start = i; + while (i < style.length) { + const ch = style[i]; + if (ch === "'" && !inDouble) inSingle = !inSingle; + else if (ch === '"' && !inSingle) inDouble = !inDouble; + else if (!inSingle && !inDouble) { + if (ch === "(") depth++; + else if (ch === ")") depth = Math.max(0, depth - 1); + else if (ch === ";" && depth === 0) break; + } + i++; + } + const decl = style.slice(start, i).trim(); + i++; + if (!decl) continue; + const colon = decl.indexOf(":"); + if (colon < 0) continue; + const key = decl.slice(0, colon).trim(); + const val = decl.slice(colon + 1).trim(); + if (!key) continue; + if (!props.has(key)) order.push(key); + props.set(key, val); + } + return { props, order }; +} + +function serializeStyleDecls(props: Map, order: string[]): string { + return order + .map((k) => `${k}: ${props.get(k) ?? ""}`) + .filter((d) => d.trim()) + .join("; "); +} + +export function patchStyleAttrString( + style: string, + property: string, + value: string | null, +): string { + const { props, order } = parseStyleDecls(style); + if (value === null) { + props.delete(property); + const idx = order.indexOf(property); + if (idx >= 0) order.splice(idx, 1); + } else { + if (!props.has(property)) order.push(property); + props.set(property, value); + } + return serializeStyleDecls(props, order); +} diff --git a/packages/studio-server/tsup.config.ts b/packages/studio-server/tsup.config.ts index 53aeca203f..296c20ff3f 100644 --- a/packages/studio-server/tsup.config.ts +++ b/packages/studio-server/tsup.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts", "helpers/draftMarkers": "src/helpers/draftMarkers.ts", "helpers/finiteMutation": "src/helpers/finiteMutation.ts", + "helpers/sourceMutation": "src/helpers/sourceMutation.ts", }, format: ["esm"], outDir: "dist", diff --git a/packages/studio/src/components/editor/DomEditOverlay.test.ts b/packages/studio/src/components/editor/DomEditOverlay.test.ts index 468bebfdfe..db1f2291bc 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.test.ts +++ b/packages/studio/src/components/editor/DomEditOverlay.test.ts @@ -345,6 +345,101 @@ describe("DomEditOverlay", () => { Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; host.remove(); }); + + it("passes the tracked hover selection when clicking the existing selection box", async () => { + const originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = function (): DOMRect { + return { + left: 0, + top: 0, + right: 800, + bottom: 450, + width: 800, + height: 450, + x: 0, + y: 0, + toJSON: () => ({}), + }; + }; + + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const selection: DomEditSelection = { + element: document.createElement("div"), + id: "hero-title", + selector: ".hero-title", + selectorIndex: 0, + sourceFile: "index.html", + tagName: "div", + label: "Hero Title", + textContent: "Hello", + textFields: [], + capabilities: { + canEditText: true, + canEditLayout: true, + canMove: false, + canApplyManualOffset: false, + canApplyManualSize: false, + canApplyManualRotation: false, + canAdjustOpacity: true, + canAdjustFill: true, + canAdjustBorderRadius: true, + canAdjustStroke: true, + canAdjustShadow: true, + canAdjustZIndex: true, + }, + computedStyle: { + display: "block", + position: "absolute", + }, + }; + const hoverSelection: DomEditSelection = { ...selection, id: "hovered-sibling" }; + const onCanvasMouseDown = vi.fn(); + const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null }; + + function Harness() { + return React.createElement(DomEditOverlay, { + ...createOverlayProps({ + iframeRef, + selection, + hoverSelection, + onSelectionChange: () => {}, + }), + onCanvasMouseDown, + }); + } + + act(() => { + root.render(React.createElement(Harness)); + }); + + await act(async () => { + await new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + }); + + const selectionBox = host.querySelector( + '[data-dom-edit-selection-box="true"]', + ) as HTMLDivElement; + expect(selectionBox).toBeTruthy(); + + act(() => { + selectionBox.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onCanvasMouseDown).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ hoverSelection }), + ); + + act(() => { + root.unmount(); + }); + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; + host.remove(); + }); }); describe("resolveDomEditCoordinateScale", () => { diff --git a/packages/studio/src/components/editor/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index f0861be649..48e563a203 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; import { type DomEditSelection } from "./domEditing"; +import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; import { useMarqueeGestures } from "./marqueeCommit"; import { MarqueeOverlay } from "./MarqueeOverlay"; import { groupAwareOverlayRect, resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry"; @@ -44,7 +45,7 @@ interface DomEditOverlayProps { allowCanvasMovement?: boolean; onCanvasMouseDown: ( event: React.MouseEvent, - options?: { preferClipAncestor?: boolean }, + options?: PreviewMouseDownOptions, ) => void; onCanvasPointerMove: ( event: React.PointerEvent, @@ -277,6 +278,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ iframeRef, boxRef, selectionRef, + hoverSelectionRef, overlayRectRef, groupOverlayItemsRef, gestureRef, @@ -336,7 +338,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ // Allow clicks anywhere on the overlay — GSAP-translated elements can // extend beyond the composition rect into the gray zone, and users need // to select/deselect them by clicking there. - onCanvasMouseDown(event, { preferClipAncestor: false }); + onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current }); if (event.shiftKey) { suppressNextBoxMouseDownRef.current = true; suppressNextBoxClickRef.current = true; @@ -401,7 +403,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ event.stopPropagation(); return; } - onCanvasMouseDown(event, { preferClipAncestor: false }); + onCanvasMouseDown(event, { hoverSelection: hoverSelectionRef.current }); }; const suppressBoxMouseDown = (e: React.MouseEvent) => { diff --git a/packages/studio/src/components/editor/domEditOverlayGestures.ts b/packages/studio/src/components/editor/domEditOverlayGestures.ts index d693e974a9..7f3c63c805 100644 --- a/packages/studio/src/components/editor/domEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/domEditOverlayGestures.ts @@ -9,6 +9,7 @@ import type { ManualOffsetDragMember } from "./manualOffsetDrag"; import type { GroupOverlayItem, OverlayRect } from "./domEditOverlayGeometry"; import type { SnapContext } from "./snapTargetCollection"; import type { SnapGuidesState } from "./SnapGuideOverlay"; +import type { PreviewMouseDownOptions } from "../../hooks/usePreviewInteraction"; export type GestureKind = "drag" | "resize" | "rotate"; @@ -161,6 +162,7 @@ export type UseDomEditOverlayGesturesOptions = { iframeRef: RefObject; boxRef: RefObject; selectionRef: RefObject; + hoverSelectionRef: RefObject; overlayRectRef: RefObject; groupOverlayItemsRef: RefObject; gestureRef: RefObject; @@ -194,9 +196,6 @@ export type UseDomEditOverlayGesturesOptions = { o?: { preferClipAncestor?: boolean }, ) => Promise >; - onCanvasMouseDown: ( - e: React.MouseEvent, - o?: { preferClipAncestor?: boolean }, - ) => void; + onCanvasMouseDown: (e: React.MouseEvent, o?: PreviewMouseDownOptions) => void; snapGuidesRef: RefObject; }; diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts index 45927cfe75..1dd6a1ffa7 100644 --- a/packages/studio/src/components/editor/domEditing.ts +++ b/packages/studio/src/components/editor/domEditing.ts @@ -31,6 +31,7 @@ export { buildDomEditTextPatchOperation, collectDomEditLayerItems, countDomEditChildLayers, + buildTextFieldChildLocator, getDomEditLayerKey, getDomEditNonEditableReason, getDomEditTargetKey, diff --git a/packages/studio/src/components/editor/domEditingLayers.test.ts b/packages/studio/src/components/editor/domEditingLayers.test.ts index 04099ae919..b817289940 100644 --- a/packages/studio/src/components/editor/domEditingLayers.test.ts +++ b/packages/studio/src/components/editor/domEditingLayers.test.ts @@ -4,11 +4,27 @@ import { collectDomEditLayerItems, resolveDomEditSelection, buildDomEditPatchTarget, + buildTextFieldChildLocator, readHfId, } from "./domEditingLayers"; +import type { DomEditTextField } from "./domEditingTypes"; const opts = { activeCompositionPath: "index.html", isMasterView: true, skipSourceProbe: true }; +function textField(overrides: Partial = {}): DomEditTextField { + return { + key: "child:0:span", + label: "Text 1", + value: "Hello", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "child", + ...overrides, + }; +} + describe("buildDomEditPatchTarget", () => { it("includes hfId when selection has hfId", () => { const target = buildDomEditPatchTarget({ @@ -172,3 +188,39 @@ describe("resolveDomEditSelection — data-hf-group capture", () => { expect(selection?.id).toBe("outside"); }); }); + +describe("buildTextFieldChildLocator", () => { + it("locates a child field using its DOM-derived sourceChildIndex", () => { + const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "child:0:span")).toEqual({ + childSelector: ":scope > span", + childIndex: 0, + }); + }); + + it("fails closed for a synthetic child field with no sourceChildIndex", () => { + // A field built by buildDefaultDomEditTextField (e.g. "add text field") + // has never been read back from the live DOM, so its true position among + // same-tag siblings is unknown. Guessing it by counting same-tag "child" + // fields elsewhere in the array can silently point at the wrong element. + const fields = [ + textField({ key: "child:0:span", sourceChildIndex: 0 }), + textField({ key: "child:new:1", tagName: "span" }), + ]; + + expect(buildTextFieldChildLocator(fields, "child:new:1")).toBeNull(); + }); + + it("returns null for a self-sourced field", () => { + const fields = [textField({ key: "self:0:div", source: "self", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "self:0:div")).toBeNull(); + }); + + it("returns null for an unknown field key", () => { + const fields = [textField({ key: "child:0:span", sourceChildIndex: 0 })]; + + expect(buildTextFieldChildLocator(fields, "missing")).toBeNull(); + }); +}); diff --git a/packages/studio/src/components/editor/domEditingLayers.ts b/packages/studio/src/components/editor/domEditingLayers.ts index 787444e8d4..3102d030f8 100644 --- a/packages/studio/src/components/editor/domEditingLayers.ts +++ b/packages/studio/src/components/editor/domEditingLayers.ts @@ -1,7 +1,3 @@ -/** - * Layer items, text fields, capabilities, selection resolution, and patch operations - * for dom editing. - */ import type { PatchOperation } from "../../utils/sourcePatcher"; import { resolveEditingAffordances, @@ -36,12 +32,20 @@ import { } from "./domEditingElement"; import { isCompositionRootLayer } from "./domEditingRootLayer"; -// ─── Text fields ──────────────────────────────────────────────────────────── - export function isEditableTextLeaf(el: HTMLElement): boolean { return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0; } +function sameTagChildIndex(el: HTMLElement): number { + let index = 0; + let sibling = el.previousElementSibling; + while (sibling) { + if (sibling.tagName === el.tagName) index += 1; + sibling = sibling.previousElementSibling; + } + return index; +} + function getTextFieldLabel( _tagName: string, index: number, @@ -57,6 +61,7 @@ function buildTextField( index: number, total: number, source: "self" | "child", + sourceChildIndex?: number, ): DomEditTextField { const tagName = el.tagName.toLowerCase(); const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`; @@ -74,6 +79,7 @@ function buildTextField( inlineStyles: getInlineStyles(el), computedStyles: getCuratedComputedStyles(el), source, + ...(sourceChildIndex == null ? {} : { sourceChildIndex }), }; } @@ -105,7 +111,9 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { }); childIdx++; } else if (isHtmlElement(node) && isEditableTextLeaf(node)) { - fields.push(buildTextField(node, childIdx, childElements.length, "child")); + fields.push( + buildTextField(node, childIdx, childElements.length, "child", sameTagChildIndex(node)), + ); childIdx++; } } @@ -113,7 +121,7 @@ export function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { } return childElements.map((child, index) => - buildTextField(child, index, childElements.length, "child"), + buildTextField(child, index, childElements.length, "child", sameTagChildIndex(child)), ); } @@ -172,14 +180,30 @@ export function buildDefaultDomEditTextField(base?: Partial): }; } -// ─── Capabilities ──────────────────────────────────────────────────────────── +export interface DomEditChildLocator { + childSelector: string; + childIndex: number; +} + +export function buildTextFieldChildLocator( + fields: DomEditTextField[], + fieldKey: string, +): DomEditChildLocator | null { + const field = fields.find((candidate) => candidate.key === fieldKey); + if (!field || field.source !== "child") return null; + // sourceChildIndex is only absent for a synthetic field that was never read + // back from the live DOM (e.g. one built by buildDefaultDomEditTextField). + // Guessing its position by counting same-tag "child" fields elsewhere in + // the array is unreliable and can silently locate the wrong element — fail + // closed instead so the caller falls back to the unsupported-structure path. + if (field.sourceChildIndex == null) return null; + + return { + childSelector: `:scope > ${field.tagName}`, + childIndex: field.sourceChildIndex, + }; +} -/** - * Build the geometry/capability half of EditableElementFacts. Section inputs - * (text/timing/animation) are irrelevant to capability resolution, so they are - * zeroed here. Shared by the wrapper and the live-selection path so the two - * fact-construction sites can't disagree. - */ function capabilityFacts(geometry: { hasStableTarget: boolean; tag: string; @@ -276,8 +300,11 @@ async function probeSourceElement( }, ); if (!response.ok) return true; - const data = (await response.json()) as { exists?: boolean }; - return data.exists !== false; + const data = await response.json(); + if (data && typeof data === "object" && "exists" in data && data.exists === false) { + return false; + } + return true; } catch { return true; } @@ -475,19 +502,28 @@ export function collectDomEditLayerItems( // ─── Patch operations ──────────────────────────────────────────────────────── -export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation { +export function buildDomEditStylePatchOperation( + property: string, + value: string | null, + childLocator?: DomEditChildLocator, +): PatchOperation { return { type: "inline-style", property, value, + ...childLocator, }; } -export function buildDomEditTextPatchOperation(value: string): PatchOperation { +export function buildDomEditTextPatchOperation( + value: string, + childLocator?: DomEditChildLocator, +): PatchOperation { return { type: "text-content", property: "text", value, + ...childLocator, }; } diff --git a/packages/studio/src/components/editor/domEditingTypes.ts b/packages/studio/src/components/editor/domEditingTypes.ts index d6ec3cafd8..c25ce19957 100644 --- a/packages/studio/src/components/editor/domEditingTypes.ts +++ b/packages/studio/src/components/editor/domEditingTypes.ts @@ -69,6 +69,7 @@ export interface DomEditTextField { inlineStyles: Record; computedStyles: Record; source: "self" | "child" | "text-node"; + sourceChildIndex?: number; } export interface DomEditSelection extends PatchTarget { diff --git a/packages/studio/src/components/editor/persistSeam.integration.test.ts b/packages/studio/src/components/editor/persistSeam.integration.test.ts new file mode 100644 index 0000000000..e3ff608861 --- /dev/null +++ b/packages/studio/src/components/editor/persistSeam.integration.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + patchElementInHtml, + type PatchOperation, + type SourceMutationTarget, +} from "@hyperframes/studio-server/source-mutation"; +import { describe, expect, it } from "vitest"; +import { + collectDomEditTextFields, + buildDomEditPatchTarget, + buildDomEditStylePatchOperation, + buildDomEditTextPatchOperation, +} from "./domEditingLayers"; +import { buildPathOffsetPatches } from "./manualEditsDomPatches"; +import { STUDIO_OFFSET_X_PROP, STUDIO_PATH_OFFSET_ATTR } from "./manualEditsTypes"; +import { makeSelection } from "../../hooks/domSelectionTestHarness"; +import { buildTextFieldChildOperations } from "../../hooks/domEditTextFieldCommitOps"; + +const testDir = dirname(fileURLToPath(import.meta.url)); +const fixtureDir = join(testDir, "../../../tests/e2e/fixtures/design-panel-qa"); + +function readFixture(relativePath: string): string { + return readFileSync(join(fixtureDir, relativePath), "utf-8"); +} + +function createSelection(input: { + id: string; + hfId: string; + tagName: string; +}): ReturnType { + const element = document.createElement(input.tagName); + element.id = input.id; + element.setAttribute("data-hf-id", input.hfId); + return { + ...makeSelection(input.id, element), + hfId: input.hfId, + }; +} + +function clientTarget(input: { id: string; hfId: string; tagName: string }): SourceMutationTarget { + return buildDomEditPatchTarget(createSelection(input)); +} + +function patchAndExpectChange( + sourceHtml: string, + target: SourceMutationTarget, + operations: PatchOperation[], +): string { + const result = patchElementInHtml(sourceHtml, target, operations); + expect(result.matched).toBe(true); + expect(result.html).not.toBe(sourceHtml); + return result.html; +} + +function parseHtml(html: string): Document { + return new DOMParser().parseFromString(html, "text/html"); +} + +function findElementInHtml(html: string, selector: string): Element { + const document = parseHtml(html); + const directMatch = document.querySelector(selector); + if (directMatch) return directMatch; + + for (const template of Array.from(document.querySelectorAll("template"))) { + const templateMatch = template.content.querySelector(selector); + if (templateMatch) return templateMatch; + } + + throw new Error(`Expected selector ${selector} to match`); +} + +function findByHfId(html: string, hfId: string): Element { + return findElementInHtml(html, `[data-hf-id="${hfId}"]`); +} + +function countOccurrences(value: string, needle: string): number { + return value.split(needle).length - 1; +} + +describe("persist seam source mutation", () => { + const indexHtml = readFixture("index.html"); + const subHtml = readFixture("compositions/qa-sub.html"); + + it("persists qa-headline text font-size style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-headline", hfId: "qa-headline", tagName: "h1" }), + [buildDomEditStylePatchOperation("font-size", "64px")], + ); + + expect(findByHfId(html, "qa-headline").getAttribute("style")).toContain("font-size: 64px"); + }); + + it("persists qa-shape fill style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }), + [buildDomEditStylePatchOperation("background-color", "#ff0000")], + ); + + expect(findByHfId(html, "qa-shape").getAttribute("style")).toContain( + "background-color: #ff0000", + ); + }); + + it("persists qa-multi text color style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-multi", hfId: "qa-multi", tagName: "div" }), + [buildDomEditStylePatchOperation("color", "#00ff00")], + ); + + expect(findByHfId(html, "qa-multi").getAttribute("style")).toContain("color: #00ff00"); + }); + + it("persists qa-image opacity style operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-image", hfId: "qa-image", tagName: "img" }), + [buildDomEditStylePatchOperation("opacity", "0.4")], + ); + + expect(findByHfId(html, "qa-image").getAttribute("style")).toContain("opacity: 0.4"); + }); + + it("persists detached jsdom path offset operations", () => { + const element = document.createElement("div"); + element.style.setProperty(STUDIO_OFFSET_X_PROP, "24px"); + + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-shape", hfId: "qa-shape", tagName: "div" }), + buildPathOffsetPatches(element), + ); + const shape = findByHfId(html, "qa-shape"); + + expect(shape.getAttribute("style")).toContain(`${STUDIO_OFFSET_X_PROP}: 24px`); + expect(shape.getAttribute("style")).toContain("translate: var(--hf-studio-offset-x, 0px)"); + expect(shape.getAttribute(STUDIO_PATH_OFFSET_ATTR)).toBe("true"); + }); + + it("persists timeline data-start attribute operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-zone-headline", hfId: "qa-zone-headline", tagName: "div" }), + [{ type: "attribute", property: "start", value: "2.5" }], + ); + + expect(findByHfId(html, "qa-zone-headline").getAttribute("data-start")).toBe("2.5"); + expect(countOccurrences(html, 'data-start="2.5"')).toBe(1); + }); + + it("persists media volume data attribute operation", () => { + const html = patchAndExpectChange( + indexHtml, + clientTarget({ id: "qa-video", hfId: "qa-video", tagName: "video" }), + [{ type: "attribute", property: "volume", value: "0.75" }], + ); + + expect(findByHfId(html, "qa-video").getAttribute("data-volume")).toBe("0.75"); + expect(html).not.toContain('data-volume="0.5"'); + }); + + it("returns matched false and unchanged html for a missing hfId target", () => { + const result = patchElementInHtml(indexHtml, { hfId: "qa-does-not-exist" }, [ + buildDomEditStylePatchOperation("font-size", "64px"), + ]); + + expect(result.matched).toBe(false); + expect(result.html).toBe(indexHtml); + }); + + it("persists sub-composition child style operation inside a template", () => { + const html = patchAndExpectChange( + subHtml, + clientTarget({ id: "qa-sub-title", hfId: "qa-sub-title", tagName: "h2" }), + [buildDomEditStylePatchOperation("font-size", "50px")], + ); + + expect(findByHfId(html, "qa-sub-title").getAttribute("style")).toContain("font-size: 50px"); + }); + + it("returns matched false for runtime-generated caption words absent from static source", () => { + const result = patchElementInHtml( + indexHtml, + { selector: "#qa-caption-host span", selectorIndex: 0 }, + [buildDomEditStylePatchOperation("color", "#ffffff")], + ); + + expect(result.matched).toBe(false); + expect(result.html).toBe(indexHtml); + }); + + it("fixes U4: child text-field style persists as an inline style on the correct child span", () => { + const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [ + buildDomEditStylePatchOperation("color", "#0000ff", { + childSelector: ":scope > span", + childIndex: 0, + }), + ]); + + const lineA = findElementInHtml(html, ".qa-line-a"); + const lineB = findElementInHtml(html, ".qa-line-b"); + expect(lineA.getAttribute("style")).toContain("color: #0000ff"); + expect(lineB.getAttribute("style")).toBeNull(); + expect(lineB.textContent).toBe("Second styled line"); + expect(html).not.toContain("<span"); + }); + + it("targets the second direct child when siblings share the same tag and class", () => { + const source = `
FirstSecond
`; + const html = patchAndExpectChange(source, { hfId: "dups" }, [ + buildDomEditStylePatchOperation("color", "#0000ff", { + childSelector: ":scope > span", + childIndex: 1, + }), + ]); + + const document = parseHtml(html); + const spans = Array.from(document.querySelectorAll(".dup")); + expect(spans[0]?.getAttribute("style")).toBeNull(); + expect(spans[1]?.getAttribute("style")).toContain("color: #0000ff"); + }); + + it("persists a child text-field content edit as plain text", () => { + const value = "A < B & C"; + const html = patchAndExpectChange(indexHtml, { hfId: "qa-multi" }, [ + buildDomEditTextPatchOperation(value, { + childSelector: ":scope > span", + childIndex: 0, + }), + ]); + + expect(findElementInHtml(html, ".qa-line-a").textContent).toBe(value); + expect(findElementInHtml(html, ".qa-line-b").textContent).toBe("Second styled line"); + expect(html).not.toContain("<span"); + }); + + it("uses same-tag source child indexes when a non-leaf sibling sits between fields", () => { + const source = `
FirstWrapperSecond
`; + const previewHost = document.createElement("div"); + previewHost.innerHTML = source; + const previewTarget = previewHost.querySelector('[data-hf-id="mixed"]'); + if (!(previewTarget instanceof HTMLElement)) throw new Error("Expected preview target"); + + const originalFields = collectDomEditTextFields(previewTarget); + const secondField = originalFields.find((field) => field.value === "Second"); + if (!secondField) throw new Error("Expected second text field"); + const nextFields = originalFields.map((field) => + field.key === secondField.key ? { ...field, value: "Second updated" } : field, + ); + const operations = buildTextFieldChildOperations(originalFields, nextFields); + if (!operations) throw new Error("Expected child operations"); + + const html = patchAndExpectChange(source, { hfId: "mixed" }, operations); + + expect(findElementInHtml(html, ".leaf-a").textContent).toBe("First"); + expect(findElementInHtml(html, ".wrapper").textContent).toBe("Wrapper"); + expect(findElementInHtml(html, ".leaf-b").textContent).toBe("Second updated"); + }); +}); diff --git a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx index 554365c21a..ae12435607 100644 --- a/packages/studio/src/components/editor/propertyPanelPrimitives.tsx +++ b/packages/studio/src/components/editor/propertyPanelPrimitives.tsx @@ -371,7 +371,10 @@ export function Section({ ); return ( -
+
+
+ + `; + + const scene = doc.getElementById("scene"); + const overlayParent = doc.getElementById("overlay-parent"); + const clickableChild = doc.getElementById("clickable-child"); + if (!scene || !overlayParent || !clickableChild) { + throw new Error("Expected preview fixture elements"); + } + + stubRect(iframe, domRect(0, 0, 400, 300)); + stubRect(scene, domRect(0, 0, 400, 300)); + stubRect(overlayParent, domRect(0, 0, 360, 260)); + stubRect(clickableChild, domRect(40, 40, 80, 24)); + doc.elementsFromPoint = () => [clickableChild, overlayParent, scene]; + + expect(getPreviewTargetFromPointer(iframe, 60, 50, "index.html")).toBe(clickableChild); + + iframe.remove(); + }); +}); diff --git a/packages/studio/src/utils/studioPreviewHelpers.ts b/packages/studio/src/utils/studioPreviewHelpers.ts index 455844b8bc..9f76b59cb5 100644 --- a/packages/studio/src/utils/studioPreviewHelpers.ts +++ b/packages/studio/src/utils/studioPreviewHelpers.ts @@ -4,6 +4,7 @@ import { isElementComputedVisible, resolveAllVisualDomEditTargets, } from "../components/editor/domEditingElement"; +import { isHtmlElement } from "../components/editor/domEditingDom"; import { getEventTargetElement } from "./studioHelpers"; interface PreviewLocalPointer { @@ -81,11 +82,90 @@ function removePointerEventsOverride(style: HTMLStyleElement | null): void { } } +const pointerEventsInheritanceFallbackByDocument = new WeakMap(); + +function needsPointerEventsInheritanceFallback(doc: Document, win: Window): boolean { + const cached = pointerEventsInheritanceFallbackByDocument.get(doc); + if (cached !== undefined) return cached; + + const parent = doc.createElement("div"); + const child = doc.createElement("div"); + parent.style.pointerEvents = "none"; + parent.appendChild(child); + const host = doc.body ?? doc.documentElement; + if (!host) return false; + + host.appendChild(parent); + const needsFallback = win.getComputedStyle(child).pointerEvents !== "none"; + parent.remove(); + pointerEventsInheritanceFallbackByDocument.set(doc, needsFallback); + return needsFallback; +} + +// Own declared pointer-events value, via computed style rather than inline +// style, so a CSS-class opt-in/opt-out (not just an inline style attribute) +// is honored when walking back down from a pointer-events:none ancestor. +function hasOwnPointerEventsOverride(el: HTMLElement, win: Window): boolean { + const value = win.getComputedStyle(el).pointerEvents; + return value !== "" && value !== "inherit" && value !== "unset"; +} + +function inheritsPointerEventsNoneFromAncestor(el: HTMLElement, win: Window): boolean { + let current = el.parentElement; + while (current) { + if (win.getComputedStyle(current).pointerEvents === "none") { + let descendant: HTMLElement | null = el; + while (descendant && descendant !== current) { + if (hasOwnPointerEventsOverride(descendant, win)) { + return win.getComputedStyle(descendant).pointerEvents === "none"; + } + descendant = descendant.parentElement; + } + return true; + } + current = current.parentElement; + } + return false; +} + +function hasAuthorPointerEventsNone(el: HTMLElement): boolean { + const win = el.ownerDocument.defaultView; + if (!win) return false; + if (win.getComputedStyle(el).pointerEvents === "none") return true; + if (!needsPointerEventsInheritanceFallback(el.ownerDocument, win)) return false; + return inheritsPointerEventsNoneFromAncestor(el, win); +} + +function collectPointerEventsNoneTargets( + elements: Iterable, +): WeakSet { + const disabled = new WeakSet(); + for (const entry of elements) { + if (isHtmlElement(entry) && hasAuthorPointerEventsNone(entry)) { + disabled.add(entry); + } + } + return disabled; +} + +// Shared tail of both pointer resolvers: hit-test candidates minus elements the +// author hid from hit-testing via pointer-events:none. +function filterAuthorInteractiveTargets( + elements: Element[], + activeCompositionPath: string | null, +): HTMLElement[] { + const pointerEventsNoneTargets = collectPointerEventsNoneTargets(elements); + return resolveAllVisualDomEditTargets(elements, { activeCompositionPath }).filter( + (el) => !pointerEventsNoneTargets.has(el), + ); +} + // Animated group members can move outside their wrapper's static layout box, so // the empty space inside a group's *visual* bounds (the member-union the overlay // draws) doesn't hit-test to the group via elementsFromPoint. Recover it: if the // point falls within a group's live member-union rect, return that wrapper. // Innermost (smallest-area) group wins for nested groups. +// fallow-ignore-next-line complexity function findGroupAtPoint(doc: Document, x: number, y: number): HTMLElement | null { let best: HTMLElement | null = null; let bestArea = Infinity; @@ -132,26 +212,39 @@ export function getPreviewTargetFromPointer( const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY); if (!localPointer) return null; - const overrideStyle = forcePointerEventsAuto(doc); + let overrideStyle = forcePointerEventsAuto(doc); try { if (typeof doc.elementsFromPoint === "function") { - const candidates = resolveAllVisualDomEditTargets( - doc.elementsFromPoint(localPointer.x, localPointer.y), - { activeCompositionPath }, - ); + const elements = doc.elementsFromPoint(localPointer.x, localPointer.y); + removePointerEventsOverride(overrideStyle); + overrideStyle = null; + const candidates = filterAuthorInteractiveTargets(elements, activeCompositionPath); const visualTarget = candidates.find((el) => !isFullBleedTarget(el, localPointer.viewport)) ?? null; if (visualTarget) return visualTarget; } + // Belt-and-suspenders: elementsFromPoint is universally supported in the + // browsers this ships in, so the override is already removed by this + // point in practice — but guard the environment without it too, so + // hasAuthorPointerEventsNone below never reads a forced-auto value. + removePointerEventsOverride(overrideStyle); + overrideStyle = null; + // No element hit (e.g. empty space inside an animated group's overlay) — fall // back to the group whose member-union contains the point, so the whole group // area is hoverable/selectable, not just where a member currently sits. const groupHit = findGroupAtPoint(doc, localPointer.x, localPointer.y); - if (groupHit && getDomLayerPatchTarget(groupHit, activeCompositionPath)) return groupHit; + if ( + groupHit && + !hasAuthorPointerEventsNone(groupHit) && + getDomLayerPatchTarget(groupHit, activeCompositionPath) + ) + return groupHit; const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return null; + if (hasAuthorPointerEventsNone(fallback)) return null; if (!isElementComputedVisible(fallback)) return null; if (isFullBleedTarget(fallback, localPointer.viewport)) return null; return fallback; @@ -180,15 +273,21 @@ export function getAllPreviewTargetsFromPointer( const localPointer = resolvePreviewLocalPointer(iframe, doc, win, clientX, clientY); if (!localPointer) return []; - const overrideStyle = forcePointerEventsAuto(doc); + let overrideStyle = forcePointerEventsAuto(doc); try { if (typeof doc.elementsFromPoint === "function") { - return resolveAllVisualDomEditTargets(doc.elementsFromPoint(localPointer.x, localPointer.y), { - activeCompositionPath, - }).filter((el) => !isFullBleedTarget(el, localPointer.viewport)); + const elements = doc.elementsFromPoint(localPointer.x, localPointer.y); + removePointerEventsOverride(overrideStyle); + overrideStyle = null; + return filterAuthorInteractiveTargets(elements, activeCompositionPath).filter( + (el) => !isFullBleedTarget(el, localPointer.viewport), + ); } const fallback = getEventTargetElement(doc.elementFromPoint(localPointer.x, localPointer.y)); if (!fallback || !getDomLayerPatchTarget(fallback, activeCompositionPath)) return []; + removePointerEventsOverride(overrideStyle); + overrideStyle = null; + if (hasAuthorPointerEventsNone(fallback)) return []; if (!isElementComputedVisible(fallback)) return []; if (isFullBleedTarget(fallback, localPointer.viewport)) return []; return [fallback]; diff --git a/packages/studio/src/utils/studioSaveDiagnostics.ts b/packages/studio/src/utils/studioSaveDiagnostics.ts index 8f4f34367f..9424aba81e 100644 --- a/packages/studio/src/utils/studioSaveDiagnostics.ts +++ b/packages/studio/src/utils/studioSaveDiagnostics.ts @@ -18,11 +18,13 @@ export interface StudioSaveFailureInput { export class StudioSaveHttpError extends Error { readonly statusCode: number; + readonly alreadyToasted: boolean; - constructor(message: string, statusCode: number) { + constructor(message: string, statusCode: number, options: { alreadyToasted?: boolean } = {}) { super(message); this.name = "StudioSaveHttpError"; this.statusCode = statusCode; + this.alreadyToasted = options.alreadyToasted ?? false; } } @@ -130,6 +132,7 @@ export function trackStudioSaveFailure(input: StudioSaveFailureInput): void { export async function createStudioSaveHttpError( response: Response, fallbackMessage: string, + options: { alreadyToasted?: boolean } = {}, ): Promise { let body = ""; try { @@ -141,7 +144,7 @@ export async function createStudioSaveHttpError( const message = detail ? `${fallbackMessage} (${response.status}): ${detail}` : `${fallbackMessage} (${response.status})`; - return new StudioSaveHttpError(message, response.status); + return new StudioSaveHttpError(message, response.status, options); } export async function retryStudioSave( diff --git a/packages/studio/tests/e2e/design-panel-qa-matrix.md b/packages/studio/tests/e2e/design-panel-qa-matrix.md new file mode 100644 index 0000000000..b75e78fd35 --- /dev/null +++ b/packages/studio/tests/e2e/design-panel-qa-matrix.md @@ -0,0 +1,143 @@ +# Design Panel QA Matrix + +Campaign artifact for `docs/plans/2026-07-02-001-fix-studio-design-panel-inputs-plan.md`. +Environment: published CLI `hyperframes@0.7.26`, embedded mode (`npx hyperframes preview` +in a scaffolded `warm-grain` project outside the repo), Chrome via agent-browser. + +## Step 0: demo-failure reproduction (baseline, pre-fix) + +Reproduced. The demo symptom ("font size does nothing") is real, deterministic, and its +root cause is the **selection layer**, not the persist pipeline. + +### S0.1 Master view: click on visible text selects the invisible top overlay + +- Action: click the "Hyperframes" H1 (from `compositions/intro.html`, embedded in `index.html`). +- Selected instead: `.grain-texture` (`hf-0qtj`, label "Grain Texture"), the full-canvas grain + overlay on `data-track-index="100"`, even though its parent `#grain-overlay-comp` has + `pointer-events: none`. +- Panel then shows generic values (Size 16px) and a Text section with an empty Content field + for a div that contains no text. +- Committing Size 72px: + - signal a (disk): `index.html` changed, `
` + - signal b (HTTP): `POST /file-mutations/patch-element/index.html` 200, `matched:true, changed:true` + - signal c (console/telemetry): nothing +- Visible effect: none (the styled element is an invisible overlay). This alone explains the + demo: every click lands on the overlay, every edit applies to it. +- Bucket: **selection/hit-testing** (new bucket; persist pipeline healthy in this leg). + +### S0.2 Sub-composition view: hover finds the element, click cannot select it + +- Setup: open `intro` in the sidebar (Master > intro breadcrumb), scrub to t=2s where the + title card is visible. +- Hover over the H1: teal highlight appears (hit-testing sees the element). +- Click (real mouse down/up at the text): no selection API call, no panel, no console error. + Reproduced with element-ref clicks and coordinate clicks. +- Bucket: **selection click-to-commit in sub-composition view**. + +### S0.3 Stale selection target carried across composition switch + +- After selecting `.grain-texture` in Master and switching the canvas to `compositions/intro.html`, + a subsequent click re-emitted the old target: `probe-element/compositions%2Fintro.html` with + `{hfId: hf-0qtj, selector: .grain-texture}` (an element that does not exist in intro.html), + followed by a selection PUT labeled "Grain Texture" with `sourceFile: compositions/intro.html`, + then `selection: null`. +- If a patch had been committed in that state it would have written to the wrong file or + silently no-oped (`matched:false`). +- Bucket: **selection state lifecycle across composition switches**. + +### S0.4 Double-click on canvas element clears selection + +- Double-click on the H1 in Master view: `PUT /selection {selection: null}`. No drill-down into + the sub-composition, no text editing mode. Users double-click text instinctively. +- Bucket: **selection UX** (candidate: intentional-but-hostile; confirm with maintainers). + +### Working in this leg + +- Persist pipeline end-to-end (patch-element → linkedom mutation → disk write → 200 with + `matched/changed`): healthy for the (wrong) selected element. +- Hover highlighting in both views. +- Panel rendering, section expansion, input commit on Enter. + +### Instrumentation notes for the full matrix + +- Fetch shim on `window.fetch` in the top document captures patch/probe/selection traffic + (studio app runs in the top document; composition renders in a shadow-DOM iframe). +- Element-ref clicks work for selection in Master view; sub-composition view needs + coordinate clicks (`mouse move/down/up`) and still fails to select (S0.2). +- Seek-slider `fill` does not move the playhead; use Play/pause or timeline clicks to scrub. +- GSAP warning noise in console: `GSAP target #a-roll not found` (from the warm-grain + captions comp; unrelated). + +## Selection-layer fixes: embedded-mode re-verification (post-fix) + +Environment: locally built CLI (commit with selection fixes), embedded mode, fixture copied +to a scratch dir outside the repo. + +- Click on empty canvas over the invisible full-canvas overlay: selection resolves to + **null** (previously: selected the overlay). S0.1 fixed for real pointer input. +- Click on visible fixture text (`#qa-headline`): selects the H1 itself; panel shows real + values (Size 48px, weight 700, content "Static Headline") instead of overlay defaults. +- Font size commit 96px: `patch-element` 200 `matched:true, changed:true`; disk gains + `

`; preview renders 96px. The demo scenario works. +- Note for future automation: `agent-browser click @ref` on an element whose DOM box is + off-viewport can land on the sidebar "Select off-canvas element" helper buttons and + select programmatically, bypassing hit-testing. Use coordinate clicks on visible pixels + for selection tests. + +## Full matrix (post selection + U3 fixes, embedded mode, locally built CLI) + +Instrument: scripted agent-browser runner (`matrix-runner.mjs`, session scratchpad) + interactive +follow-ups. Signals per cell: patch/gsap-mutation HTTP response, disk content, computed style, +reload survival. + +### Selection (click on canvas, Inspector enabled) + +| Archetype | Result | +| ----------------------- | ------------------------------------------------------------------------------------------------- | +| Static text (h1) | selects the element itself | +| Multi-span child (span) | selects the span itself | +| GSAP-tweened box | selects the element | +| Keyframed box | selects the element | +| Image | selects the element (canEditStyles true) | +| Shape div | selects the element | +| Video | selects the element (visible only inside its clip window; hidden outside, correctly unselectable) | +| Runtime caption word | falls back to the parent host (runtime nodes cannot persist; by design) | +| Sub-composition child | selects the child with sourceFile pointing at the sub-composition file | + +### Inputs (all persist to disk with matched:true/changed:true and survive reload) + +- Text on h1: size, content, weight, line-height, letter-spacing, align, case, style. Span-self + size also works. +- Layout on shape: W, H, rotation persist as `tl.set(...)` in the GSAP script (designed manual-edit + path); z-index persists inline. +- 3D: rotationX persists via the `gsap-mutations` endpoint (ok:true, changed:true). +- GSAP-tweened element: Layout X persists as `gsap.set("#qa-tween-box", { x: 40 })` appended to the + script. Works; note: a load-time `gsap.set` on an element that also has an x tween is semantically + debatable (starting value shifts) — flag for maintainers, not a broken input. +- Timing: start persists as `data-start="0.20"` (normalized to 2 decimals). +- Video section (titled "Video", not "Media"): volume slider persists `data-volume="0.8"`; + object-fit select persists `object-fit: cover`. +- Transparency: opacity range persists `opacity: 0.8`; blend select persists + `mix-blend-mode: multiply`. +- Radius text input persists `border-radius: 24px`; Effects blur range persists `blur(4px)`; + Clip overflow select persists `overflow: hidden`. +- Sub-composition child: Text size persists to `compositions/qa-sub.html` (`font-size: 48px`). + +### Confirmed bugs + +- **U4 child text-field escaping** (persist-level, confirmed by the headless harness test + "documents U4 bug: child text-field style persists as escaped markup"): editing a child field of + a multi-field element serializes markup into a `text-content` op that the server escapes. + +### Notes and paper cuts (not input bugs) + +- Inspector defaults OFF on a fresh embedded-mode load; canvas clicks silently do nothing until it + is toggled on. Zero feedback for the user in that state. +- Fill color picker: opens with a hex input reflecting the current color; persist path verified + green by the headless harness (fill style op); scripted popup commit was flaky (focus-sensitive + popup), verified manually instead. +- Color grading section absent for img/video: expected (flag `VITE_STUDIO_ENABLE_COLOR_GRADING` + defaults off). +- Automation notes: media/timing cells must run with the playhead inside the clip window (a + data-start edit hides the element at t=0, which is correct but confuses naive re-runs); commit + fires on Enter/blur only when the draft differs from the last value. diff --git a/packages/studio/tests/e2e/design-panel.mjs b/packages/studio/tests/e2e/design-panel.mjs new file mode 100644 index 0000000000..e82466fba2 --- /dev/null +++ b/packages/studio/tests/e2e/design-panel.mjs @@ -0,0 +1,381 @@ +#!/usr/bin/env node +// Design-panel e2e smoke: drives agent-browser against a running preview of the +// design-panel-qa fixture and asserts selection + one representative input per +// panel section persists to disk. +// +// Usage: +// 1. Copy fixtures/design-panel-qa to a scratch dir OUTSIDE the repo. +// 2. Start the CLI there: node /packages/cli/dist/cli.js preview --no-open +// 3. STUDIO_URL=http://localhost:3002 PROJECT_DIR= node design-panel.mjs +// +// Requires the agent-browser CLI on PATH. Exits non-zero on any failed cell. +// Automation notes (learned the hard way, see design-panel-qa-matrix.md): +// - Inspector defaults OFF on fresh load; the script toggles it on. +// - Selection commits on real CDP mouse events at canvas coordinates; element-ref +// clicks on off-viewport nodes can hit sidebar helper buttons instead. +// - Commit fires on Enter/blur only when the draft differs from the last value. +// - Range inputs need input+change+pointerup; selects need change. +// - Panel sections are found by the app's own `data-panel-section` attribute, not +// by matching h3 display text, and specific fields are found by their sibling +// label span (or, where there's no label, by being the section's only input of +// that type) — not by guessing the fixture's current value. Both survive wording +// or fixture-default changes that would otherwise break this script silently. +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; + +const STUDIO_URL = process.env.STUDIO_URL || "http://localhost:3002"; +const PROJECT_DIR = process.env.PROJECT_DIR; +if (!PROJECT_DIR) { + console.error("PROJECT_DIR env var is required (scratch copy of the design-panel-qa fixture)"); + process.exit(2); +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let failures = 0; + +function ab(...args) { + try { + return execFileSync("agent-browser", args, { encoding: "utf8", timeout: 30000 }); + } catch (e) { + return "ERR: " + (e.stdout || "") + (e.stderr || e.message); + } +} +function abEval(code) { + const out = ab("eval", code).trim(); + let v; + try { + v = JSON.parse(out); + } catch { + return out; + } + if (typeof v === "string") { + try { + return JSON.parse(v); + } catch { + return v; + } + } + return v; +} +function check(id, ok, detail) { + console.log(`${ok ? "PASS" : "FAIL"} ${id}${detail ? " " + detail : ""}`); + if (!ok) { + failures += 1; + console.log(` patchLog: ${JSON.stringify(abEval("window.__patchLog"))}`); + } +} +function disk(file, needle) { + try { + return readFileSync(`${PROJECT_DIR}/${file}`, "utf8").includes(needle); + } catch { + return false; + } +} +// The patch fetch resolving client-side doesn't guarantee the server's file +// write has landed yet (seen in practice on the very first commit of a run). +// Poll disk instead of asserting immediately after the in-browser signal. +async function waitForDisk(file, needle, timeout = 3000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (disk(file, needle)) return true; + await sleep(100); + } + return false; +} +// Polls a page-context boolean expression instead of sleeping a fixed duration: +// faster on a healthy run, and it fails loudly (returns false) rather than +// silently passing on a slow one. +async function waitFor(expr, { timeout = 8000, interval = 150 } = {}) { + const start = Date.now(); + while (Date.now() - start < timeout) { + if (abEval(expr) === true) return true; + await sleep(interval); + } + return false; +} + +const HELPERS = String.raw` +(() => { + window.__patchLog = window.__patchLog || []; + window.__qaFault = window.__qaFault || null; + if (!window.__qaShim) { + window.__qaShim = true; + const orig = window.fetch; + window.fetch = async function(...args) { + const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || ''; + if (window.__qaFault && url.includes(window.__qaFault.match)) { + const fault = window.__qaFault; + window.__qaFault = null; // one-shot + window.__patchLog.push({ t: Date.now(), url, status: fault.status, req: null, resp: '(fault injected)' }); + return new Response(JSON.stringify({ error: 'e2e injected fault' }), { + status: fault.status, + headers: { 'content-type': 'application/json' }, + }); + } + const isMut = url.includes('file-mutations') || url.includes('gsap-mutations') || (args[1] && args[1].method && args[1].method !== 'GET' && url.includes('/api/')); + let body = null; + if (isMut && args[1] && typeof args[1].body === 'string') body = args[1].body.slice(0, 1500); + const res = await orig.apply(this, args); + if (isMut) { + const clone = res.clone(); + let respText = ''; + try { respText = (await clone.text()).slice(0, 300); } catch {} + window.__patchLog.push({ t: Date.now(), url, status: res.status, req: body, resp: respText }); + } + return res; + }; + } + const qa = {}; + qa.frame = () => { + const frames = []; + const collect = (root) => { + for (const f of root.querySelectorAll('iframe')) { try { if (f.contentDocument && f.contentDocument.querySelector('#design-panel-qa')) frames.push(f); } catch {} } + for (const el of root.querySelectorAll('*')) { if (el.shadowRoot) collect(el.shadowRoot); } + }; + collect(document); + return frames.sort((a, b) => b.getBoundingClientRect().width - a.getBoundingClientRect().width)[0] || null; + }; + qa.coords = (sel) => { + const f = qa.frame(); + if (!f) return null; + const el = f.contentDocument.querySelector(sel); + if (!el) return null; + const r = el.getBoundingClientRect(); + const fr = f.getBoundingClientRect(); + const scale = fr.width / 1920; + const x = fr.x + (r.x + Math.min(r.width, 80) / 2) * scale; + const y = fr.y + (r.y + Math.min(r.height, 60) / 2) * scale; + if (x < fr.x || x > fr.x + fr.width || y < fr.y || y > fr.y + fr.height) return null; + return [Math.round(x), Math.round(y)]; + }; + qa.lastSel = () => { + const s = window.__patchLog.filter(p => p.url.includes('/selection')).slice(-1)[0]; + if (!s) return null; + const req = s.req || ''; + if (req.includes('"selection":null')) return { nullSel: true }; + const m = (k) => { const r = new RegExp('"' + k + '":"([^"]*)"').exec(req); return r ? r[1] : null; }; + return { label: m('label'), selector: m('selector'), hfId: m('hfId'), src: m('sourceFile') }; + }; + qa.clear = () => { window.__patchLog.length = 0; return 'cleared'; }; + qa.section = (slug) => document.querySelector('[data-panel-section="' + slug + '"]'); + qa.sectionInputs = (slug) => { + const section = qa.section(slug); + if (!section) return null; + return [...section.querySelectorAll('input, textarea, select')]; + }; + qa.ensureSection = (slug) => { + const section = qa.section(slug); + if (!section) return 'no section: ' + slug; + const inputs = qa.sectionInputs(slug); + if (inputs && inputs.length) return 'open'; + const header = section.querySelector('button'); + if (header) header.click(); + return 'clicked'; + }; + // Walks up a few ancestor levels looking for a preceding label — covers + // both a field whose label is a direct sibling of its input (MetricField) and + // one whose label sits beside the input's wrapper (a hand-rolled SliderControl + // row). More robust than hardcoding either shape. + qa.labelFor = (el) => { + let node = el; + for (let i = 0; i < 3 && node; i++) { + const sib = node.previousElementSibling; + if (sib && sib.tagName === 'SPAN' && sib.textContent.trim()) return sib.textContent.trim(); + node = node.parentElement; + } + return null; + }; + qa.pickByLabel = (slug, label) => { + const inputs = qa.sectionInputs(slug); + if (!inputs) return null; + return inputs.find((el) => qa.labelFor(el) === label) || null; + }; + qa.pickByType = (slug, type) => { + const inputs = qa.sectionInputs(slug); + if (!inputs) return null; + const matches = inputs.filter((el) => el.type === type); + return matches.length === 1 ? matches[0] : null; + }; + qa.setEl = (el, value) => { + if (!el) return { error: 'no matching field' }; + const from = el.value; + if (el.tagName === 'SELECT') { + const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('change', { bubbles: true })); + return { from, to: value }; + } + el.focus(); + const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(proto, 'value').set; + setter.call(el, value); + el.dispatchEvent(new Event('input', { bubbles: true })); + if (el.type === 'range') { + el.dispatchEvent(new Event('change', { bubbles: true })); + el.dispatchEvent(new PointerEvent('pointerup', { bubbles: true })); + } else { + el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + el.blur(); + } + return { from, to: value }; + }; + qa.enableInspector = () => { + if ([...document.querySelectorAll('h3, [class*="panel"]')].length && document.body.textContent.includes('Select an element')) return 'on'; + const btn = [...document.querySelectorAll('button')].find(b => b.textContent.trim() === 'Inspector'); + if (!btn) return 'no inspector button'; + btn.click(); + return 'toggled'; + }; + qa.injectFault = (match, status) => { window.__qaFault = { match, status: status || 500 }; return 'armed'; }; + window.__qa = qa; + return 'qa ready'; +})()`; + +const FRAME_SIGNATURE_EXPR = + "(() => { const f = window.__qa.frame(); if (!f) return null; const r = f.getBoundingClientRect(); return Math.round(r.x*1000+r.y*100+r.width*10+r.height); })()"; + +// A prior commit can resize the property panel (opening it, or its content +// changing height), which shifts the preview frame's on-page position. Computing +// click coordinates mid-reflow silently clicks the wrong spot — the click still +// lands on the overlay, so it doesn't error, it just selects nothing (or the +// wrong element). Wait for two consecutive reads of the frame's rect to agree +// before trusting it, instead of guessing how long a reflow takes. +async function waitForStableFrame({ tries = 10, interval = 100 } = {}) { + let prev = abEval(FRAME_SIGNATURE_EXPR); + for (let i = 0; i < tries; i++) { + await sleep(interval); + const next = abEval(FRAME_SIGNATURE_EXPR); + if (next != null && next === prev) return true; + prev = next; + } + return false; +} + +async function select(sel) { + abEval("window.__qa.clear()"); + await waitForStableFrame(); + const coords = abEval(`window.__qa.coords(${JSON.stringify(sel)})`); + if (!Array.isArray(coords)) return { error: "no coords" }; + ab("mouse", "move", String(coords[0]), String(coords[1])); + ab("mouse", "down", "left"); + await sleep(150); // deliberate gesture delay to simulate a real click, not an async wait + ab("mouse", "up", "left"); + await waitFor("window.__qa.lastSel() !== null"); + return abEval("window.__qa.lastSel()"); +} + +// `pick` locates the field to edit: byLabel("Size") finds the input beside a +// "Size" label; byType("range") finds the section's sole range input. Neither +// depends on knowing the fixture's current value ahead of time. +const byLabel = (label) => (slug) => + `window.__qa.pickByLabel(${JSON.stringify(slug)}, ${JSON.stringify(label)})`; +const byType = (type) => (slug) => + `window.__qa.pickByType(${JSON.stringify(slug)}, ${JSON.stringify(type)})`; + +async function commit(sectionSlug, pick, value) { + abEval("window.__qa.clear()"); + abEval(`window.__qa.ensureSection(${JSON.stringify(sectionSlug)})`); + await waitFor(`(window.__qa.sectionInputs(${JSON.stringify(sectionSlug)}) || []).length > 0`); + const r = abEval(`window.__qa.setEl(${pick(sectionSlug)}, ${JSON.stringify(value)})`); + await waitFor("window.__patchLog.length > 0"); + return r; +} + +async function openStudio(url) { + ab("open", url); + await waitFor("document.readyState === 'complete' && !!document.querySelector('button')", { + timeout: 15000, + }); + abEval(HELPERS); + // "a button exists" fires on the app shell alone; wait for the actual preview + // iframe to mount before handing control back, or a caller's first frame() + // lookup races the composition load and comes back null. + await waitFor("!!window.__qa.frame()", { timeout: 15000 }); +} + +// fallow-ignore-next-line complexity +async function main() { + await openStudio(`${STUDIO_URL}/?v=e2e${Date.now()}`); + abEval("window.__qa.enableInspector()"); + await waitFor("document.body.textContent.includes('Select an element')", { timeout: 5000 }); + + let s = await select("#qa-headline"); + check("select.headline", s && s.selector === "#qa-headline", JSON.stringify(s)); + let r = await commit("text", byLabel("Size"), "72px"); + check( + "text.size", + await waitForDisk("index.html", "font-size: 72px"), + `set=${JSON.stringify(r)}`, + ); + + s = await select("#qa-shape"); + check("select.shape", s && s.selector === "#qa-shape", JSON.stringify(s)); + r = await commit("transparency", byType("range"), "80"); + check( + "style.opacity", + await waitForDisk("index.html", "opacity: 0.8"), + `set=${JSON.stringify(r)}`, + ); + r = await commit("radius", byLabel("All"), "24"); + check( + "style.radius", + await waitForDisk("index.html", "border-radius: 24px"), + `set=${JSON.stringify(r)}`, + ); + + s = await select("#qa-video"); + check("select.video", s && s.selector === "#qa-video", JSON.stringify(s)); + r = await commit("video", byLabel("Volume"), "80"); + check( + "media.volume", + await waitForDisk("index.html", 'data-volume="0.8"'), + `set=${JSON.stringify(r)}`, + ); + + s = await select("#qa-sub-title"); + check( + "select.sub-title", + s && s.selector === "#qa-sub-title" && s.src === "compositions/qa-sub.html", + JSON.stringify(s), + ); + r = await commit("text", byLabel("Size"), "48px"); + check( + "sub.text-size", + await waitForDisk("compositions/qa-sub.html", "font-size: 48px"), + `set=${JSON.stringify(r)}`, + ); + + // Fault injection: the server rejects the patch — the panel must surface the + // rejection and the value already on disk (72px, from the first cell) must + // survive untouched, not silently take on the value that failed to persist. + s = await select("#qa-headline"); + check("select.headline-again", s && s.selector === "#qa-headline", JSON.stringify(s)); + abEval("window.__qa.injectFault('file-mutations/patch-element', 500)"); + r = await commit("text", byLabel("Size"), "90px"); + await waitFor('document.body.textContent.includes("Couldn\'t save")', { timeout: 4000 }); + const toastShown = abEval('document.body.textContent.includes("Couldn\'t save")'); + check("fault.toast-shown", toastShown === true, `set=${JSON.stringify(r)}`); + check( + "fault.no-persist", + !disk("index.html", "font-size: 90px"), + "rejected value must not reach disk", + ); + check( + "fault.prior-value-survives", + disk("index.html", "font-size: 72px"), + "prior committed value must survive", + ); + + // Reload survival for the headline edit. + await openStudio(`${STUDIO_URL}/?v=r${Date.now()}`); + const survived = abEval( + `(() => { const f = window.__qa.frame(); const el = f && f.contentDocument.querySelector('#qa-headline'); return el ? f.contentWindow.getComputedStyle(el).fontSize : null; })()`, + ); + check("reload.survival", survived === "72px", String(survived)); + + console.log(failures === 0 ? "ALL PASS" : `${failures} FAILURES`); + process.exit(failures === 0 ? 0 : 1); +} +main().catch((e) => { + console.error("RUNNER ERROR", e); + process.exit(1); +}); diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/assets/test.mp4 b/packages/studio/tests/e2e/fixtures/design-panel-qa/assets/test.mp4 new file mode 100644 index 0000000000..2f24de9aae Binary files /dev/null and b/packages/studio/tests/e2e/fixtures/design-panel-qa/assets/test.mp4 differ diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/compositions/qa-sub.html b/packages/studio/tests/e2e/fixtures/design-panel-qa/compositions/qa-sub.html new file mode 100644 index 0000000000..cf0a7173c0 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/design-panel-qa/compositions/qa-sub.html @@ -0,0 +1,35 @@ + diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/hyperframes.json b/packages/studio/tests/e2e/fixtures/design-panel-qa/hyperframes.json new file mode 100644 index 0000000000..5fb1d6d872 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/design-panel-qa/hyperframes.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://hyperframes.heygen.com/schema/hyperframes.json", + "registry": "https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry", + "paths": { + "blocks": "compositions", + "components": "compositions/components", + "assets": "assets" + } +} diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html b/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html new file mode 100644 index 0000000000..b5e3754014 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/design-panel-qa/index.html @@ -0,0 +1,248 @@ + + + + + Design Panel QA Fixture + + + + +
+ + + + +
+

Static Headline

+
+ + +
+
+ First styled line + Second styled line +
+
+ + +
+
+
+ + +
+
+
+ + +
+ QA test pattern +
+ + +
+
+
+ + +
+
+
+ + +
+
+ + + + diff --git a/packages/studio/tests/e2e/fixtures/design-panel-qa/package.json b/packages/studio/tests/e2e/fixtures/design-panel-qa/package.json new file mode 100644 index 0000000000..38c02f1d97 --- /dev/null +++ b/packages/studio/tests/e2e/fixtures/design-panel-qa/package.json @@ -0,0 +1,4 @@ +{ + "name": "design-panel-qa-fixture", + "private": true +} diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts index 4e31c81836..f4099711fa 100644 --- a/packages/studio/vite.config.ts +++ b/packages/studio/vite.config.ts @@ -174,6 +174,10 @@ export default defineConfig({ resolve: { alias: { "@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"), + "@hyperframes/studio-server/source-mutation": resolve( + __dirname, + "../studio-server/src/helpers/sourceMutation.ts", + ), }, }, build: {