diff --git a/packages/sdk/src/document.ts b/packages/sdk/src/document.ts index 76774c0ae..a9fa6d714 100644 --- a/packages/sdk/src/document.ts +++ b/packages/sdk/src/document.ts @@ -10,6 +10,7 @@ import { parseHTML } from "linkedom"; import { ensureHfIds } from "@hyperframes/core/hf-ids"; +import { parseGsapScriptAcornForWrite } from "@hyperframes/core/gsap-parser-acorn"; import { findRoot, getElementStyles, isNewHostBoundary } from "./engine/model.js"; import type { HyperFramesElement, SdkDocument } from "./types.js"; @@ -37,8 +38,60 @@ function ownText(el: Element): string | null { return trimmed.length > 0 ? trimmed : null; } +// Parsing the GSAP script (acorn AST walk) is the expensive part and depends +// only on the script text, so memoize the {tween id, selector} pairs by script. +// Selector→hf-id resolution still runs each call — it depends on the live DOM, +// which changes on dispatch. Single-entry cache covers the hot path (same comp, +// repeated getElements() rebuilds) and stays bounded. +let gsapLocatedCacheKey: string | null = null; +let gsapLocatedCacheVal: Array<{ id: string; selector: string }> = []; + +function parseLocatedCached(script: string): Array<{ id: string; selector: string }> { + if (gsapLocatedCacheKey === script) return gsapLocatedCacheVal; + const parsed = parseGsapScriptAcornForWrite(script); + gsapLocatedCacheVal = parsed + ? parsed.located.map(({ id, animation }) => ({ id, selector: animation.targetSelector })) + : []; + gsapLocatedCacheKey = script; + return gsapLocatedCacheVal; +} + +/** + * Map each element's data-hf-id → the GSAP tween ids targeting it. Tween ids + * come from the acorn parser's stable `targetSelector-method-position` scheme — + * the SAME id-space the studio-api read path and the SDK GSAP ops use, so these + * ids are dispatchable as-is via setGsapTween/removeGsapTween. Best-effort: a + * malformed selector or unparseable script yields no entries (animationIds: []). + */ +function buildAnimationIdMap(document: Document): Map { + const map = new Map(); + const script = extractGsapScript(document); + if (!script) return map; + for (const { id, selector } of parseLocatedCached(script)) { + if (!selector) continue; + let matches: Element[] = []; + try { + matches = Array.from(document.querySelectorAll(selector)); + } catch { + continue; // selector not valid for querySelectorAll — skip + } + for (const el of matches) { + const hfId = el.getAttribute("data-hf-id"); + if (!hfId) continue; + const list = map.get(hfId); + if (list) list.push(id); + else map.set(hfId, [id]); + } + } + return map; +} + // fallow-ignore-next-line complexity -function buildElement(el: Element, scopePrefix: string): HyperFramesElement | null { +function buildElement( + el: Element, + scopePrefix: string, + animationIdsByHfId: Map, +): HyperFramesElement | null { const tag = el.tagName.toLowerCase(); if (EXCLUDED_TAGS.has(tag)) return null; @@ -82,7 +135,7 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu const children: HyperFramesElement[] = []; for (const child of Array.from(el.children)) { - const built = buildElement(child, childPrefix); + const built = buildElement(child, childPrefix, animationIdsByHfId); if (built) children.push(built); } @@ -98,16 +151,18 @@ function buildElement(el: Element, scopePrefix: string): HyperFramesElement | nu start, duration, trackIndex, - animationIds: [], + animationIds: animationIdsByHfId.get(id) ?? [], }; } // fallow-ignore-next-line complexity function extractGsapScript(doc: Document): string | null { - // GSAP script is the first +`.trim(); + + it("attaches the parser's stable tween id to the targeted element", async () => { + const comp = await openComposition(GSAP_HTML); + const box = comp.getElement("hf-box"); + expect(box?.animationIds.length).toBe(1); + // Stable id-space shared with studio-api / GSAP ops: targetSelector-method-position. + expect(box?.animationIds[0]).toContain("hf-box"); + expect(box?.animationIds[0]).toContain("-to-"); + }); + + it("leaves untargeted elements with an empty animationIds", async () => { + const comp = await openComposition(GSAP_HTML); + expect(comp.getElement("hf-plain")?.animationIds).toEqual([]); + }); + + it("the populated id is dispatchable as a removeGsapTween target", async () => { + const comp = await openComposition(GSAP_HTML); + const id = comp.getElement("hf-box")?.animationIds[0]; + expect(id).toBeDefined(); + if (id) expect(comp.can({ type: "removeGsapTween", animationId: id }).ok).toBe(true); + }); + + it("attaches multiple distinct tween ids when one element has several tweens", async () => { + const html = ` +
+
box
+ +
`.trim(); + const ids = (await openComposition(html)).getElement("hf-box")?.animationIds ?? []; + expect(ids.length).toBe(2); + expect(new Set(ids).size).toBe(2); // distinct + }); + + it("fans a shared-selector tween out to every matched element", async () => { + const html = ` +
+
a
+
b
+ +
`.trim(); + const comp = await openComposition(html); + const a = comp.getElement("hf-a")?.animationIds ?? []; + const b = comp.getElement("hf-b")?.animationIds ?? []; + expect(a.length).toBe(1); + expect(b).toEqual(a); // same tween id on both matched elements + }); +}); diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 556bb0e6e..b4bd05c49 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser"; import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import type { EditHistoryKind } from "../utils/editHistory"; +import type { ShadowGsapOp } from "../utils/sdkShadow"; export interface MutationResult { ok: boolean; @@ -18,6 +19,8 @@ export interface CommitMutationOptions { softReload?: boolean; skipReload?: boolean; beforeReload?: () => void; + /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */ + shadowGsapOp?: ShadowGsapOp; } export type CommitMutation = ( diff --git a/packages/studio/src/hooks/useGsapAnimationOps.ts b/packages/studio/src/hooks/useGsapAnimationOps.ts index ebd332c02..a184807ac 100644 --- a/packages/studio/src/hooks/useGsapAnimationOps.ts +++ b/packages/studio/src/hooks/useGsapAnimationOps.ts @@ -1,8 +1,8 @@ import { useCallback } from "react"; -import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; +import type { Composition } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { roundTo3 } from "../utils/rounding"; -import { runShadowGsapTween } from "../utils/sdkShadow"; +import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow"; import { assignGsapTargetAutoIdIfNeeded, ensureElementAddressable, @@ -33,27 +33,34 @@ export function useGsapAnimationOps({ animationId: string, updates: { duration?: number; ease?: string; position?: number }, ) => { + // Shadow op (server animationId shares the SDK id-space): existence via + // runShadowGsapTween (live session) + value fidelity via the chokepoint. + const shadowGsapOp: ShadowGsapOp = { + kind: "set", + animationId, + properties: { duration: updates.duration, ease: updates.ease, position: updates.position }, + }; commitMutationSafely( selection, { type: "update-meta", animationId, updates }, - { - label: "Edit GSAP animation", - coalesceKey: `gsap:${animationId}:meta`, - }, + { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp }, ); + if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely], + [commitMutationSafely, sdkSession], ); const deleteGsapAnimation = useCallback( (selection: DomEditSelection, animationId: string) => { + const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId }; commitMutationSafely( selection, { type: "delete", animationId, stripStudioEdits: true }, - { label: "Delete GSAP animation" }, + { label: "Delete GSAP animation", shadowGsapOp }, ); + if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp); }, - [commitMutationSafely], + [commitMutationSafely, sdkSession], ); const deleteAllForSelector = useCallback( @@ -103,6 +110,26 @@ export function useGsapAnimationOps({ fromTo: { x: 0, y: 0, opacity: 1 }, }; + // Shadow op (server stays authoritative). "set" has no SDK method, so it + // is not shadowed; otherwise: existence via runShadowGsapTween (live) + + // value fidelity via the chokepoint (shadowGsapOp in options). + const shadowGsapOp: ShadowGsapOp | undefined = + selection.hfId && method !== "set" + ? { + kind: "add", + target: selection.hfId, + tween: { + method, + position, + duration, + ease: "power2.out", + ...(method === "fromTo" + ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } + : { properties: toDefaults[method] ?? { opacity: 1 } }), + }, + } + : undefined; + await commitMutation( selection, { @@ -115,25 +142,10 @@ export function useGsapAnimationOps({ properties: toDefaults[method] ?? { opacity: 1 }, fromProperties: method === "fromTo" ? { opacity: 0 } : undefined, }, - { label: `Add GSAP ${method} animation` }, + { label: `Add GSAP ${method} animation`, shadowGsapOp }, ); - // Shadow: dispatch the equivalent addGsapTween to the SDK (server stays - // authoritative). "set" has no SDK method, so it is not shadowed. - // ponytail: only add is shadowed — delete/update key on the server's - // animationId, which doesn't resolve in the SDK's independent id-space. - if (sdkSession && selection.hfId && method !== "set") { - const tween: GsapTweenSpec = { - method, - position, - duration, - ease: "power2.out", - ...(method === "fromTo" - ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] } - : { properties: toDefaults[method] ?? { opacity: 1 } }), - }; - runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween }); - } + if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp); }, [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession], ); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 6403a2fa1..6c0abe9fc 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { applySoftReload } from "../utils/gsapSoftReload"; +import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity"; import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers"; import { GsapMutationHttpError, @@ -67,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra } if (result.changed === false) return; domEditSaveTimestampRef.current = Date.now(); + // Shadow value fidelity: diff the SDK's GSAP writer output against the + // server's, from the same pre-op file. Fire-and-forget; server authoritative. + // Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via + // useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce, + // useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up. + // scriptText is null when the composition has no GSAP script; nothing to diff. + const fidelityArgs = resolveGsapFidelityArgs( + sdkSession, + options.shadowGsapOp, + result.before, + result.scriptText, + ); + if (fidelityArgs) { + void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript); + } if (result.before != null && result.after != null) { await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } }); } @@ -80,7 +96,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra reloadPreview(); } onCacheInvalidate(); - }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]); + }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]); const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath); const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast); const propertyOps = useGsapPropertyDebounce(commitMutationSafely); diff --git a/packages/studio/src/hooks/useSafeGsapCommitMutation.ts b/packages/studio/src/hooks/useSafeGsapCommitMutation.ts index 92130c54d..7fe0cb8e8 100644 --- a/packages/studio/src/hooks/useSafeGsapCommitMutation.ts +++ b/packages/studio/src/hooks/useSafeGsapCommitMutation.ts @@ -1,20 +1,7 @@ import { useCallback } from "react"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics"; - -type CommitMutationOptions = { - label: string; - coalesceKey?: string; - softReload?: boolean; - skipReload?: boolean; - beforeReload?: () => void; -}; - -type CommitMutation = ( - selection: DomEditSelection, - mutation: Record, - options: CommitMutationOptions, -) => Promise; +import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes"; type TrackGsapSaveFailure = ( error: unknown, diff --git a/packages/studio/src/utils/sdkShadow.test.ts b/packages/studio/src/utils/sdkShadow.test.ts index efc15fd1a..3fd0b931d 100644 --- a/packages/studio/src/utils/sdkShadow.test.ts +++ b/packages/studio/src/utils/sdkShadow.test.ts @@ -4,8 +4,12 @@ import { runShadowDelete, runShadowTiming, runShadowGsapTween, + runShadowGsapFidelity, + gsapFidelityMismatches, + resolveGsapFidelityArgs, SdkShadowMismatch, } from "./sdkShadow"; +import type { ShadowGsapOp } from "./sdkShadow"; import type { PatchOperation } from "./sourcePatcher"; import { openComposition } from "@hyperframes/sdk"; @@ -219,13 +223,24 @@ describe("runShadowTiming", () => { }); describe("runShadowGsapTween", () => { - it("dispatches add against a real timeline and reports success", async () => { + it("add reports success and the new tween lands on the target's animationIds", async () => { const session = await openComposition(GSAP_HTML); + const before = session.getElement("hf-box")?.animationIds.length ?? 0; runShadowGsapTween(session, { kind: "add", target: "hf-box", tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, }); + expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1); + expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); + }); + + it("remove drops the tween from animationIds and reports parity", async () => { + const session = await openComposition(GSAP_HTML); + const animationId = session.getElement("hf-box")?.animationIds[0]; + expect(animationId).toBeDefined(); + runShadowGsapTween(session, { kind: "remove", animationId: animationId! }); + expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId); expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 }); }); @@ -244,3 +259,118 @@ describe("runShadowGsapTween", () => { }); }); }); + +const SCRIPT_A = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2); +window.__timelines["t"] = tl;`; + +describe("gsapFidelityMismatches", () => { + it("returns no mismatches for identical scripts", () => { + expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]); + }); + + it("flags a per-field value drift (duration)", () => { + const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9"); + const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A); + expect(mismatches.some((m) => m.property === "duration")).toBe(true); + }); + + it("flags a tween present in one script but not the other", () => { + const empty = `var tl = gsap.timeline({ paused: true }); +window.__timelines["t"] = tl;`; + const mismatches = gsapFidelityMismatches(empty, SCRIPT_A); + expect(mismatches.some((m) => m.property === "tween")).toBe(true); + }); + + it("does NOT flag property key-order differences (canonical compare)", () => { + const ab = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0); +window.__timelines["t"] = tl;`; + const ba = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0); +window.__timelines["t"] = tl;`; + expect(gsapFidelityMismatches(ab, ba)).toEqual([]); + }); + + it("does NOT flag number-vs-string-equivalent property values", () => { + const numeric = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0); +window.__timelines["t"] = tl;`; + const stringy = `var tl = gsap.timeline({ paused: true }); +tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0); +window.__timelines["t"] = tl;`; + expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]); + }); +}); + +describe("runShadowGsapFidelity", () => { + const BEFORE_HTML = `
+
+ +
`; + + it("reports zero mismatches when the SDK output matches the server script", async () => { + // Produce the "server" script by applying the same op via the SDK, so a + // faithful SDK writer must reproduce it exactly. + const ref = await openComposition(BEFORE_HTML); + const op = { + kind: "add", + target: "hf-box", + tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, + } as const; + ref.addGsapTween(op.target, op.tween); + const serverScript = + ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""; + + await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); + expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 }); + }); + + it("reports mismatches when the server script diverges", async () => { + const op = { + kind: "add", + target: "hf-box", + tween: { method: "to", properties: { x: 100 }, duration: 0.5 }, + } as const; + const ref = await openComposition(BEFORE_HTML); + ref.addGsapTween(op.target, op.tween); + const serverScript = ( + ref.serialize().match(/]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "" + ).replace("100", "999"); + + await runShadowGsapFidelity(BEFORE_HTML, op, serverScript); + const ev = lastShadow(); + expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true }); + expect(ev?.mismatchCount as number).toBeGreaterThan(0); + }); +}); + +describe("resolveGsapFidelityArgs (chokepoint wiring)", () => { + const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" }; + const session = {} as object; + + it("returns narrowed args when session, op, before, and serverScript are all present", () => { + expect(resolveGsapFidelityArgs(session, op, "before", "tl.to(...)")).toEqual({ + before: "before", + op, + serverScript: "tl.to(...)", + }); + }); + + it("returns null when no session (shadow not wired)", () => { + expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull(); + }); + + it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => { + expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull(); + }); + + it("returns null when serverScript is null (composition has no GSAP script)", () => { + expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull(); + }); + + it("returns null when before is null", () => { + expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull(); + }); +}); diff --git a/packages/studio/src/utils/sdkShadow.ts b/packages/studio/src/utils/sdkShadow.ts index a58a61909..4a9c23aad 100644 --- a/packages/studio/src/utils/sdkShadow.ts +++ b/packages/studio/src/utils/sdkShadow.ts @@ -367,12 +367,15 @@ export type ShadowGsapOp = | { kind: "remove"; animationId: string }; /** - * Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the - * tween lives in the GSAP `) — HTML5 ignores junk + // before the `>`, e.g. `` or `` (CodeQL js/bad-tag-filter). + const scripts = html.match(/]*>([\s\S]*?)<\/script[^>]*>/gi); + if (!scripts) return null; + for (const block of scripts) { + const body = block.replace(/^]*>/i, "").replace(/<\/script[^>]*>$/i, ""); + if (isGsapScriptBody(body)) return body; + } + return null; +} + +function animById(script: string): Map { + const map = new Map(); + const parsed = parseGsapScriptAcorn(script); + for (const anim of parsed.animations) map.set(anim.id, anim); + return map; +} + +// The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT +// writers, so the same tween can serialize with different property key order or +// number-vs-string forms. Compare canonically — sort keys, coerce numeric +// strings — so only real value drift registers, not formatting differences. + +function numericEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + const na = typeof a === "string" ? Number(a) : a; + const nb = typeof b === "string" ? Number(b) : b; + return ( + typeof na === "number" && + typeof nb === "number" && + !Number.isNaN(na) && + !Number.isNaN(nb) && + na === nb + ); +} + +function canonicalProps(obj: Record | undefined): string { + if (!obj) return "{}"; + const out: Record = {}; + for (const key of Object.keys(obj).sort()) { + const v = obj[key]; + // normalize "0.5" → 0.5 so a number/string writer difference isn't drift + out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v; + } + return JSON.stringify(out); +} + +/** + * Structurally diff two GSAP scripts by tween id. Reports a tween present in + * one but not the other, and per-field value drift (method, position, duration, + * ease, properties, fromProperties). Comparison is canonical (see above) so + * writer formatting differences do not produce false mismatches. + */ +// fallow-ignore-next-line complexity +export function gsapFidelityMismatches( + sdkScript: string, + serverScript: string, +): SdkShadowMismatch[] { + const sdk = animById(sdkScript); + const server = animById(serverScript); + const mismatches: SdkShadowMismatch[] = []; + const ids = new Set([...sdk.keys(), ...server.keys()]); + for (const id of ids) { + const a = sdk.get(id); + const b = server.get(id); + if (!a || !b) { + mismatches.push({ + kind: "value_mismatch", + hfId: id, + property: "tween", + expected: b ? "present" : "absent", + actual: a ? "present" : "absent", + }); + continue; + } + // [property, sdk-value, server-value, equal?] + const fields: Array<[string, unknown, unknown, boolean]> = [ + ["method", a.method, b.method, a.method === b.method], + ["position", a.position, b.position, numericEqual(a.position, b.position)], + ["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)], + ["ease", a.ease, b.ease, a.ease === b.ease], + [ + "properties", + a.properties, + b.properties, + canonicalProps(a.properties) === canonicalProps(b.properties), + ], + [ + "fromProperties", + a.fromProperties, + b.fromProperties, + canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties), + ], + ]; + for (const [property, av, bv, equal] of fields) { + if (!equal) { + mismatches.push({ + kind: "value_mismatch", + hfId: id, + property, + expected: bv == null ? null : JSON.stringify(bv), + actual: av == null ? null : JSON.stringify(av), + }); + } + } + } + return mismatches; +} + +export interface GsapFidelityArgs { + before: string; + op: ShadowGsapOp; + serverScript: string; +} + +/** + * Wiring gate for the commitMutation chokepoint: return the narrowed fidelity + * args only when there is a live session, a typed shadow op, and both the + * pre-op file and the server's resulting script to diff against (scriptText is + * null when the composition has no GSAP script). Returns null otherwise. Pure + + * narrowing so the wiring decision is unit-testable without rendering the hook + * and the caller needs no non-null assertions. + */ +export function resolveGsapFidelityArgs( + sdkSession: unknown, + shadowGsapOp: ShadowGsapOp | undefined, + before: string | null | undefined, + serverScript: string | null | undefined, +): GsapFidelityArgs | null { + if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null; + return { before, op: shadowGsapOp, serverScript }; +} + +/** + * Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op + * file, apply the same tween op, serialize, and diff the SDK's GSAP script + * against the server's resulting script. Emits sdk_shadow_dispatch op: + * "gsap_fidelity". Async, fire-and-forget; server stays authoritative. + */ +export async function runShadowGsapFidelity( + beforeHtml: string, + gsapOp: ShadowGsapOp, + serverScript: string, +): Promise { + if (!STUDIO_SDK_SHADOW_ENABLED) return; + // No server script to diff against → skip the (costly) openComposition. + if (!serverScript || !beforeHtml) return; + try { + const session = await openComposition(beforeHtml); + session.batch(() => { + if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween); + else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties); + else session.removeGsapTween(gsapOp.animationId); + }); + const sdkScript = extractGsapScript(session.serialize()); + if (sdkScript == null) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_fidelity", + dispatched: false, + reason: "no_sdk_script", + mismatchCount: 0, + }); + return; + } + const mismatches = gsapFidelityMismatches(sdkScript, serverScript); + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_fidelity", + dispatched: true, + mismatchCount: mismatches.length, + mismatches: JSON.stringify(mismatches), + }); + } catch (err) { + trackStudioEvent("sdk_shadow_dispatch", { + op: "gsap_fidelity", + dispatched: false, + reason: "fidelity_error", + error: String(err), + mismatchCount: 0, + }); + } +}