feat(studio): s7.5 — delete shadow scaffolding; keep cutover flag (dark launch) (#1462)

Removes the SDK shadow telemetry: STUDIO_SDK_SHADOW_ENABLED, sdkShadow.ts +
sdkShadowGsapFidelity/GsapKeyframe/Numeric and their tests, the runShadow*
call-sites across the GSAP/timeline hooks, and the onDomEditPersisted shadow
callback in useDomEditSession. Moves patchOpsToSdkEditOps into sdkCutover.ts.

KEEPS STUDIO_SDK_CUTOVER_ENABLED as a dark-launch kill-switch — default false,
enable per-environment via VITE_STUDIO_SDK_CUTOVER_ENABLED=true. shouldUseSdkCutover
stays flag-gated. The stack can merge with zero behavior change; cutover is
validated by flipping the flag, not by removing it.

Co-authored-by: Miguel Ángel <miguel07alm@protonmail.com>
This commit is contained in:
Vance Ingalls
2026-06-17 16:27:03 -07:00
committed by GitHub
co-authored by Miguel Ángel
parent 0ca1a8a9d1
commit ca1a8a6879
15 changed files with 54 additions and 2110 deletions
+37 -2
View File
@@ -1,10 +1,9 @@
import type { MutableRefObject } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { Composition, EditOp } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { EditHistoryKind } from "./editHistory";
import type { PatchOperation } from "./sourcePatcher";
import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability";
import { patchOpsToSdkEditOps } from "./sdkShadow";
import { trackStudioEvent } from "./studioTelemetry";
const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
@@ -14,6 +13,42 @@ const CUTOVER_OP_TYPES = new Set<PatchOperation["type"]>([
"html-attribute",
]);
/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;
for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: op.property.startsWith("data-") ? op.property : `data-${op.property}`,
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
}
if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}
return result;
}
export function shouldUseSdkCutover(
flagEnabled: boolean,
hasSession: boolean,
-606
View File
@@ -1,606 +0,0 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import {
patchOpsToSdkEditOps,
runShadowDelete,
runShadowTiming,
runShadowGsapTween,
runShadowGsapFidelity,
gsapFidelityMismatches,
resolveGsapFidelityArgs,
SdkShadowMismatch,
} from "./sdkShadow";
import type { ShadowGsapOp } from "./sdkShadow";
import { makeSelectorResolver } from "./sdkShadowGsapFidelity";
import type { PatchOperation } from "./sourcePatcher";
import { openComposition } from "@hyperframes/sdk";
import { Window } from "happy-dom";
// Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
trackedEvents.push({ event, props }),
}));
beforeEach(() => {
trackedEvents.length = 0;
});
const lastShadow = () =>
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
const BASE_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
</body></html>`;
describe("patchOpsToSdkEditOps", () => {
it("maps inline-style ops to a single setStyle EditOp", () => {
const ops: PatchOperation[] = [
{ type: "inline-style", property: "color", value: "#00f" },
{ type: "inline-style", property: "opacity", value: "0.5" },
];
const result = patchOpsToSdkEditOps("hf-box", ops);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "setStyle",
target: "hf-box",
styles: { color: "#00f", opacity: "0.5" },
});
});
it("maps text-content op to setText EditOp", () => {
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World" }];
const result = patchOpsToSdkEditOps("hf-box", ops);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({ type: "setText", target: "hf-box", value: "World" });
});
it("maps attribute op to setAttribute with data- prefix", () => {
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
const result = patchOpsToSdkEditOps("hf-box", ops);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "setAttribute",
target: "hf-box",
name: "data-name",
value: "hero",
});
});
it("maps html-attribute op to setAttribute without prefix", () => {
const ops: PatchOperation[] = [
{ type: "html-attribute", property: "contenteditable", value: "true" },
];
const result = patchOpsToSdkEditOps("hf-box", ops);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: "setAttribute",
target: "hf-box",
name: "contenteditable",
value: "true",
});
});
it("handles null value for attribute removal", () => {
const ops: PatchOperation[] = [{ type: "html-attribute", property: "hidden", value: null }];
const result = patchOpsToSdkEditOps("hf-box", ops);
expect(result[0]).toEqual({
type: "setAttribute",
target: "hf-box",
name: "hidden",
value: null,
});
});
it("returns empty array for unknown op types", () => {
const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
expect(patchOpsToSdkEditOps("hf-box", ops)).toHaveLength(0);
});
});
describe("sdkShadowDispatch (integration)", () => {
it("applies ops and returns no mismatches when SDK matches expected values", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
const result = sdkShadowDispatch(session, "hf-box", ops);
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0);
expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
});
// fallow-ignore-next-line code-duplication
it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [
{ type: "inline-style", property: "background-color", value: "rgb(255, 79, 88)" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // was 1 before the kebab→camel read-back fix
expect(session.getElement("hf-box")?.inlineStyles.backgroundColor).toBe("rgb(255, 79, 88)");
});
it("returns dispatched:false when hfId not found in session", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
const result = sdkShadowDispatch(session, "hf-missing", ops);
expect(result.dispatched).toBe(false);
expect(result.mismatches).toHaveLength(1);
expect(result.mismatches[0]).toMatchObject<SdkShadowMismatch>({
kind: "element_not_found",
hfId: "hf-missing",
});
});
it("applies text op and reads back via session.getElement", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "Updated" }];
sdkShadowDispatch(session, "hf-box", ops);
expect(session.getElement("hf-box")?.text).toBe("Updated");
});
// Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a
// trailing-whitespace-only difference between the op value and the snapshot must
// not flag.
it("does NOT false-mismatch trailing-whitespace-only text difference", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }];
const result = sdkShadowDispatch(session, "hf-box", ops);
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // trimmed both sides
});
// Empty-string op value vs an absent (null) snapshot text must collapse to equal
// — both mean "no text content".
it("treats empty-string text op and null snapshot text as equal", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const EMPTY_HTML = /* html */ `<!DOCTYPE html>
<html><body><img data-hf-id="hf-img" src="x.png" /></body></html>`;
const session = await openComposition(EMPTY_HTML);
const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }];
const result = sdkShadowDispatch(session, "hf-img", ops);
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // "" vs null → both null
});
// Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style
// read-back already reads only the AUTHORED style attribute (getElementStyles →
// parseStyleAttr), never computed styles. The transform-origin divergence
// (expected null actual "center center") was a genuine SDK bug — setStyle
// removal of a HYPHENATED property silently no-opped because setElementStyles
// deleted the kebab key while the style map is keyed camelCase. Now FIXED in
// the SDK (model.ts setElementStyles normalizes the key via toCamel), so the
// shadow sees parity: removal applies and there is no mismatch.
it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const TO_HTML = /* html */ `<!DOCTYPE html>
<html><body><div data-hf-id="hf-box" style="transform-origin: center center">x</div></body></html>`;
const session = await openComposition(TO_HTML);
const ops: PatchOperation[] = [
{ type: "inline-style", property: "transform-origin", value: null },
];
const result = sdkShadowDispatch(session, "hf-box", ops);
// The SDK now removes the hyphenated property, so the shadow read-back agrees.
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0);
});
it("applies attribute op and reads back via session.getElement", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
const ops: PatchOperation[] = [{ type: "attribute", property: "name", value: "hero" }];
sdkShadowDispatch(session, "hf-box", ops);
expect(session.getElement("hf-box")?.attributes["data-name"]).toBe("hero");
});
// fallow-ignore-next-line code-duplication
it("does NOT false-mismatch studio-internal data-hf-* marker attributes", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
// path-offset drags emit these already-data-prefixed, SDK-excluded markers.
const ops: PatchOperation[] = [
{ type: "attribute", property: "data-hf-studio-path-offset", value: "true" },
];
const result = sdkShadowDispatch(session, "hf-box", ops);
expect(result.dispatched).toBe(true);
expect(result.mismatches).toHaveLength(0); // filtered, not double-prefixed + flagged
});
it("returns dispatch_error when dispatch throws — does not propagate", async () => {
const { sdkShadowDispatch } = await import("./sdkShadow");
const session = await openComposition(BASE_HTML);
// Poison dispatch so it throws on any call
session.dispatch = () => {
throw new Error("sdk internal error");
};
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "red" }];
let result: ReturnType<typeof sdkShadowDispatch> | undefined;
expect(() => {
result = sdkShadowDispatch(session, "hf-box", ops);
}).not.toThrow();
expect(result!.dispatched).toBe(false);
expect(result!.mismatches).toHaveLength(1);
expect(result!.mismatches[0]).toMatchObject<SdkShadowMismatch>({
kind: "dispatch_error",
hfId: "hf-box",
error: expect.stringContaining("sdk internal error"),
});
});
});
const TIMING_HTML = /* html */ `<!DOCTYPE html>
<html><body>
<div data-hf-id="hf-clip" data-start="0" data-duration="1" data-track="0">clip</div>
</body></html>`;
const GSAP_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
window.__timelines["t"] = tl;</script>
</div>`;
const NO_TIMELINE_HTML = `<div data-hf-id="hf-stage" data-hf-root>
<div data-hf-id="hf-box"></div>
<script>gsap.defaults({ ease: "power1.out" });
window.__timelines = {};</script>
</div>`;
describe("runShadowDelete", () => {
it("removes the element from the SDK session and reports parity", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, "hf-box");
expect(session.getElement("hf-box")).toBeNull();
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
});
it("reports no_hf_id when selection has no hf-id", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, null);
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: false, reason: "no_hf_id" });
});
it("reports cannot_dispatch when the element is not addressable", async () => {
const session = await openComposition(BASE_HTML);
runShadowDelete(session, "hf-missing");
expect(lastShadow()).toMatchObject({
op: "delete",
dispatched: false,
reason: "cannot_dispatch",
});
});
// Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a
// bare hf-id collides between a sub-composition element (scopedId
// "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement
// resolves the bare id via resolveScoped → querySelector (document-order-first,
// removes the INNER instance), but getElement prefers the canonical top-level
// match (scopedId === id) which SURVIVES. The shadow then correctly reports
// expected "removed" / actual "present". The readback here is correct (it checks
// the same id it dispatched); the fix belongs in the SDK's id resolution
// (resolveScoped vs getElement agreement), not in this file.
const DUP_ID_HTML = /* html */ `<!DOCTYPE html><html><body>
<div data-hf-id="hf-root" data-hf-root>
<div data-hf-id="hf-host" data-composition-file="sub.html">
<div data-hf-id="hf-dup">inner</div>
</div>
<div data-hf-id="hf-dup">outer</div>
</div>
</body></html>`;
it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => {
const session = await openComposition(DUP_ID_HTML);
runShadowDelete(session, "hf-dup");
// SDK fix (agree removeElement/getElement on duplicate bare ids): both now
// resolve a bare id to the canonical (top-level) instance, so removeElement
// drops exactly the element the readback checks → no mismatch. (Previously
// removeElement dropped the inner instance while the top-level survived,
// which this shadow correctly flagged; that divergence is now fixed.)
expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
});
});
describe("runShadowTiming", () => {
it("applies timing and reports parity against the snapshot", async () => {
const session = await openComposition(TIMING_HTML);
runShadowTiming(session, "hf-clip", { start: 2, duration: 3, trackIndex: 1 });
const el = session.getElement("hf-clip");
expect(el?.start).toBe(2);
expect(el?.duration).toBe(3);
expect(el?.trackIndex).toBe(1);
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
});
// Fix 1: float-precision tolerance. The SDK computes durations arithmetically
// (returning e.g. 3.0999999999999996); the server stores the rounded literal
// (3.1). A relative epsilon must treat these as equal, while a real difference
// still flags. A fake session returns the imprecise value on read-back.
type FakeTiming = { start?: number; duration?: number; trackIndex?: number };
function fakeTimingSession(readback: FakeTiming) {
return {
can: () => ({ ok: true }),
batch: (fn: () => void) => fn(),
dispatch: () => {},
getElement: () => readback,
} as unknown as Parameters<typeof runShadowTiming>[0];
}
it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => {
const session = fakeTimingSession({ duration: 3.0999999999999996 });
runShadowTiming(session, "hf-clip", { duration: 3.1 });
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
});
it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => {
const session = fakeTimingSession({ start: 21.360000000000014 });
runShadowTiming(session, "hf-clip", { start: 21.36 });
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
});
it("STILL flags a real duration difference (3.1 vs 3.5)", () => {
const session = fakeTimingSession({ duration: 3.5 });
runShadowTiming(session, "hf-clip", { duration: 3.1 });
expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 });
});
});
describe("runShadowGsapTween", () => {
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 });
});
it("reports cannot_dispatch (E_NO_GSAP_TIMELINE) when the script has no timeline", async () => {
const session = await openComposition(NO_TIMELINE_HTML);
runShadowGsapTween(session, {
kind: "add",
target: "hf-box",
tween: { method: "to", properties: { x: 100 } },
});
expect(lastShadow()).toMatchObject({
op: "gsap",
dispatched: false,
reason: "cannot_dispatch",
code: "E_NO_GSAP_TIMELINE",
});
});
});
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("does NOT flag sub-ULP float-formatting noise in duration", () => {
// 3.1 vs 3.0999999999999996 is the same value after writer round-trips;
// relative-epsilon compare must treat it as equal, not drift.
const sdk = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0);
window.__timelines["t"] = tl;`;
const server = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0);
window.__timelines["t"] = tl;`;
expect(gsapFidelityMismatches(sdk, server)).toEqual([]);
});
it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => {
const sdk = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const server = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0);
window.__timelines["t"] = tl;`;
const mismatches = gsapFidelityMismatches(sdk, server);
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([]);
});
it("matches the same element across different selector forms when a resolver is given", () => {
// SDK writes [data-hf-id="hf-x"], server writes .x — same element, same tween.
const sdk = `var tl = gsap.timeline({ paused: true });
tl.to("[data-hf-id=\\"hf-x\\"]", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const server = `var tl = gsap.timeline({ paused: true });
tl.to(".x", { x: 200, duration: 0.8 }, 0.5);
window.__timelines["t"] = tl;`;
const resolve = (sel: string) => (/hf-x|\.x/.test(sel) ? "hf-x" : sel);
// Without a resolver: selector-form divergence → present/absent mismatch.
expect(gsapFidelityMismatches(sdk, server).length).toBeGreaterThan(0);
// With a resolver: matched by element → no mismatch.
expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
});
// Drive makeSelectorResolver against a real DOM (happy-dom shims the
// browser-only DOMParser the resolver depends on; the studio test env is node).
describe("makeSelectorResolver unifies selector forms (real DOM)", () => {
const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser;
beforeEach(() => {
(globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser;
});
afterEach(() => {
(globalThis as { DOMParser?: unknown }).DOMParser = origDomParser;
});
it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => {
// Element carries all three forms; the server may emit #id or .class while
// the SDK emits [data-hf-id]. All must resolve to the same canonical key.
const html = `<div data-hf-id="hf-9flp" class="caption-layer" id="intro-layer"></div>`;
const resolve = makeSelectorResolver(html);
const viaHfId = resolve('[data-hf-id="hf-9flp"]');
expect(resolve(".caption-layer")).toBe(viaHfId);
expect(resolve("#intro-layer")).toBe(viaHfId);
});
it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => {
const html = `<div data-hf-id="hf-9flp" class="caption-layer"></div>`;
const resolve = makeSelectorResolver(html);
const sdkScript = `var tl = gsap.timeline({ paused: true });
tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
const serverScript = `var tl = gsap.timeline({ paused: true });
tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0);
window.__timelines["t"] = tl;`;
// Without unification these flag present/absent; the resolver collapses them.
expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0);
expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]);
});
it("collapses different selector forms for an element WITHOUT a data-hf-id", () => {
// No hf-id present: the resolver must still key both forms to the same node
// (not leave .class vs #id as distinct raw-selector keys).
const html = `<div class="caption-layer" id="intro-layer"></div>`;
const resolve = makeSelectorResolver(html);
expect(resolve(".caption-layer")).toBe(resolve("#intro-layer"));
// And it is NOT the raw selector fallback.
expect(resolve(".caption-layer")).not.toBe(".caption-layer");
});
});
});
describe("runShadowGsapFidelity", () => {
const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
<div data-hf-id="hf-box" style="opacity:0"></div>
<script>var tl = gsap.timeline({ paused: true });
window.__timelines["t"] = tl;</script>
</div>`;
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(/<script\b[^>]*>([\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(/<script\b[^>]*>([\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, "<html>before</html>", "tl.to(...)")).toEqual({
before: "<html>before</html>",
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();
});
});
-517
View File
@@ -1,517 +0,0 @@
/**
* SDK shadow dispatch utilities for Stage 7 Step 3b.
*
* Shadow mode keeps the server patch path authoritative while also dispatching
* the equivalent op to the SDK session, then compares the result to detect
* addressing gaps (blocker E: no-hf-id elements) and serialization drift
* (blocker B: linkedom whole-doc serialize). Results are reported as structured
* mismatches for telemetry — no user-visible change.
*/
import type { Composition } from "@hyperframes/sdk";
import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { relEqual } from "./sdkShadowNumeric";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import type { PatchOperation } from "./sourcePatcher";
// ─── Op mapping ──────────────────────────────────────────────────────────────
/**
* Map Studio PatchOperations for a given hf-id to SDK EditOps.
*
* Multiple inline-style ops are coalesced into a single setStyle (SDK batches
* style changes naturally). One SDK op is emitted per non-style op.
*/
// "attribute" PatchOperations carry the data- attribute NAME. Studio passes
// some already prefixed (e.g. "data-hf-studio-path-offset") and some bare
// (e.g. "name"); prefix only when needed, never double-prefix.
function attrName(property: string): string {
return property.startsWith("data-") ? property : `data-${property}`;
}
// The SDK element model excludes data-hf-* attributes (document.ts skips them),
// so shadowing studio-internal markers (data-hf-studio-path-offset, etc.) can
// never match — drop those ops from the shadow instead of false-mismatching.
function isShadowableOp(op: PatchOperation): boolean {
if (op.type === "attribute") return !attrName(op.property).startsWith("data-hf-");
if (op.type === "html-attribute") return !op.property.startsWith("data-hf-");
return true;
}
// PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
// runShadowDispatch to flag any unmapped type as visible telemetry rather than
// silently dropping it (see the unmapped_type guard there).
const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
"inline-style",
"text-content",
"attribute",
"html-attribute",
]);
export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
const result: EditOp[] = [];
const styles: Record<string, string | null> = {};
let hasStyles = false;
for (const op of ops) {
if (op.type === "inline-style") {
styles[op.property] = op.value;
hasStyles = true;
} else if (op.type === "text-content") {
result.push({ type: "setText", target: hfId, value: op.value ?? "" });
} else if (op.type === "attribute") {
result.push({
type: "setAttribute",
target: hfId,
name: attrName(op.property),
value: op.value,
});
} else if (op.type === "html-attribute") {
result.push({ type: "setAttribute", target: hfId, name: op.property, value: op.value });
}
// unknown op types produce no SDK op
}
if (hasStyles) {
result.unshift({ type: "setStyle", target: hfId, styles });
}
return result;
}
// ─── Shadow result types ──────────────────────────────────────────────────────
export interface SdkShadowMismatch {
kind: "element_not_found" | "value_mismatch" | "dispatch_error";
hfId: string;
property?: string;
expected?: string | null;
actual?: string | null | undefined;
error?: string;
}
export interface SdkShadowResult {
/** False if the element was not found in the SDK session. */
dispatched: boolean;
mismatches: SdkShadowMismatch[];
}
// ─── Shadow dispatch ──────────────────────────────────────────────────────────
type ElementSnapshot = ReturnType<Composition["getElement"]>;
type OpFields = {
property: string;
expected: string | null | undefined;
actual: string | null | undefined;
};
type FlatSnapshot = {
styles: Record<string, string | null>;
attrs: Record<string, string | null>;
text: string | null;
};
function flattenSnapshot(snap: ElementSnapshot): FlatSnapshot {
return {
styles: snap?.inlineStyles ?? {},
attrs: Object.fromEntries(
Object.entries(snap?.attributes ?? {}).map(([k, v]) => [k, v ?? null]),
),
text: snap?.text ?? null,
};
}
type OpFieldResolver = (op: PatchOperation, flat: FlatSnapshot) => OpFields;
// Snapshot inlineStyles are camelCase (CSSStyleDeclaration convention); PatchOperation
// style properties are kebab-case ("background-color"). Convert for read-back, else
// every hyphenated property false-mismatches against a null actual.
function kebabToCamel(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
// Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
// An empty string and absent text (null) are treated as equivalent (collapsed
// to null) so "" vs null does not flag — both mean "no text content".
function normalizeText(value: string | null | undefined): string | null {
if (value == null) return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
"inline-style": (op, flat) => ({
property: op.property,
expected: op.value,
actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
}),
// snapshot.text is already TRIMMED; trim the expected op value to match, so
// trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
// collapsed in checkOpParity. A genuinely different text value still flags.
"text-content": (op, flat) => ({
property: "text",
expected: normalizeText(op.value),
actual: normalizeText(flat.text),
}),
attribute: (op, flat) => ({
property: attrName(op.property),
expected: op.value ?? null,
actual: flat.attrs[attrName(op.property)] ?? null,
}),
"html-attribute": (op, flat) => ({
property: op.property,
expected: op.value ?? null,
actual: flat.attrs[op.property] ?? null,
}),
};
function resolveOpFields(op: PatchOperation, flat: FlatSnapshot): OpFields | null {
return OP_FIELD_RESOLVERS[op.type]?.(op, flat) ?? null;
}
function checkOpParity(
op: PatchOperation,
flat: FlatSnapshot,
hfId: string,
): SdkShadowMismatch | null {
const fields = resolveOpFields(op, flat);
if (!fields || fields.actual === fields.expected) return null;
return { kind: "value_mismatch", hfId, ...fields };
}
/**
* Dispatch PatchOperations to the SDK session and return a parity report.
*
* If the element is not found by hfId, returns dispatched:false with a
* element_not_found mismatch (signals blocker E — element has no hf-id or
* SDK can't address it).
*
* On success, verifies that the SDK element snapshot reflects the applied
* values. Value mismatches indicate serialization or normalization drift.
*
* **persist:error drift risk**: the HTTP adapter fires persist:error on
* network failure but the SDK session is already mutated at that point. If
* the server file was not updated (e.g. 503), subsequent shadow parity
* comparisons here will see a diverged SDK session and produce false
* positives. Before flipping STUDIO_SDK_DISPATCH_ENABLED, verify the shadow
* window is clear of persist:error events.
*/
export function sdkShadowDispatch(
session: Composition,
hfId: string,
ops: PatchOperation[],
): SdkShadowResult {
if (!session.getElement(hfId)) {
return { dispatched: false, mismatches: [{ kind: "element_not_found", hfId }] };
}
// Drop studio-internal markers the SDK model can't represent (data-hf-*), so
// canvas-drag/path-offset edits don't false-mismatch on bookkeeping attrs.
const shadowable = ops.filter(isShadowableOp);
try {
const sdkOps = patchOpsToSdkEditOps(hfId, shadowable);
session.batch(() => {
for (const op of sdkOps) session.dispatch(op);
});
} catch (err) {
return {
dispatched: false,
mismatches: [{ kind: "dispatch_error", hfId, error: String(err) }],
};
}
const flat = flattenSnapshot(session.getElement(hfId));
const mismatches = shadowable
.map((op) => checkOpParity(op, flat, hfId))
.filter((m): m is SdkShadowMismatch => m !== null);
return { dispatched: true, mismatches };
}
// ─── Telemetry reporting ──────────────────────────────────────────────────────
/**
* Shadow-dispatch ops to the SDK session and emit sdk_shadow_dispatch telemetry.
* Despite the telemetry focus, this function does mutate the SDK session — it
* is not read-only. No-op when STUDIO_SDK_SHADOW_ENABLED is false.
*/
// Property-path mismatches carry user content (inline-style values, edited
// text) in expected/actual. Scrub before telemetry: fully redact text-content
// values, length-cap the rest. The in-memory parity result keeps raw values.
function redactValueForTelemetry(
property: string | undefined,
value: string | null | undefined,
): string | null | undefined {
if (value == null) return value;
if (property === "text") return `[redacted len=${value.length}]`;
return value.length > 64 ? `${value.slice(0, 64)}` : value;
}
function redactMismatchesForTelemetry(mismatches: SdkShadowMismatch[]): SdkShadowMismatch[] {
return mismatches.map((m) => ({
...m,
expected: redactValueForTelemetry(m.property, m.expected),
actual: redactValueForTelemetry(m.property, m.actual),
}));
}
export function runShadowDispatch(
session: Composition,
selection: DomEditSelection,
ops: PatchOperation[],
): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
const hfId = selection.hfId;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
// Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
// doesn't map. PatchOperation.type is a closed union today, but emit a visible
// unmapped_type event if a future type ever slips through, so the gap surfaces
// in telemetry instead of vanishing.
// Map to the type string before find, so a future unmapped type is read as a
// plain string (no object cast; find on the closed union narrows to never).
const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
if (unmappedType !== undefined) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: false,
reason: "unmapped_type",
type: unmappedType,
mismatchCount: 0,
});
return;
}
const result = sdkShadowDispatch(session, hfId, ops);
trackStudioEvent("sdk_shadow_dispatch", {
op: "property",
dispatched: result.dispatched,
mismatchCount: result.mismatches.length,
mismatches: JSON.stringify(redactMismatchesForTelemetry(result.mismatches)),
});
}
// ─── Shadow for non-PatchOperation ops (delete / timing / GSAP) ───────────────
//
// These ops never flow through persistDomEditOperations, so the property-path
// shadow above never sees them. Each runner keeps the server authoritative and
// only observes the SDK: can() pre-checks addressing/validity (pure, no
// mutation — works even for GSAP, which has no element-snapshot value), then a
// dispatch into the live session with a snapshot-based parity check.
//
// Parity coverage by op:
// delete → getElement(id) === null (full)
// timing → snapshot.start/duration/trackIndex (full)
// gsap → tween id present/absent in animationIds (existence only — the
// tween's property values are script-level, not in the snapshot)
/**
* can()-gated shadow dispatch. Emits sdk_shadow_dispatch tagged with `opLabel`.
* Mutates the SDK session (not read-only); server stays authoritative.
* No-op when STUDIO_SDK_SHADOW_ENABLED is false.
*/
function runShadowEditOp(
session: Composition,
op: EditOp,
opLabel: string,
dispatchAndCheck: () => SdkShadowMismatch[],
): void {
const verdict = session.can(op);
if (!verdict.ok) {
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: false,
reason: "cannot_dispatch",
code: verdict.code,
mismatchCount: 0,
});
return;
}
let mismatches: SdkShadowMismatch[];
try {
mismatches = dispatchAndCheck();
} catch (err) {
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: false,
reason: "dispatch_error",
error: String(err),
mismatchCount: 0,
});
return;
}
trackStudioEvent("sdk_shadow_dispatch", {
op: opLabel,
dispatched: true,
mismatchCount: mismatches.length,
mismatches: JSON.stringify(mismatches),
});
}
/** Shadow an element delete. Parity: the element is gone from the SDK session. */
export function runShadowDelete(session: Composition, hfId: string | null | undefined): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "delete",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
const op: EditOp = { type: "removeElement", target: hfId };
runShadowEditOp(session, op, "delete", () => {
session.batch(() => session.dispatch(op));
return session.getElement(hfId)
? [
{
kind: "value_mismatch",
hfId,
property: "exists",
expected: "removed",
actual: "present",
},
]
: [];
});
}
export interface ShadowTiming {
start?: number;
duration?: number;
trackIndex?: number;
}
// start/duration tolerate float-precision drift (SDK computes them
// arithmetically, server stores a rounded literal) via the shared relative
// epsilon; trackIndex (integer track slot) is compared exactly.
function timingFieldEqual(
key: keyof ShadowTiming,
actual: number | null | undefined,
expected: number,
): boolean {
if (typeof actual === "number" && key !== "trackIndex") {
return relEqual(actual, expected);
}
return actual === expected;
}
/** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
export function runShadowTiming(
session: Composition,
hfId: string | null | undefined,
timing: ShadowTiming,
): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
if (!hfId) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "timing",
dispatched: false,
reason: "no_hf_id",
mismatchCount: 0,
});
return;
}
const op: EditOp = { type: "setTiming", target: hfId, ...timing };
runShadowEditOp(session, op, "timing", () => {
session.batch(() => session.dispatch(op));
const el = session.getElement(hfId);
const mismatches: SdkShadowMismatch[] = [];
const fields: Array<[keyof ShadowTiming, number | null | undefined]> = [
["start", el?.start],
["duration", el?.duration],
["trackIndex", el?.trackIndex],
];
for (const [key, actual] of fields) {
const expected = timing[key];
if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
mismatches.push({
kind: "value_mismatch",
hfId,
property: key,
expected: String(expected),
actual: actual == null ? null : String(actual),
});
}
return mismatches;
});
}
export type ShadowGsapOp =
| { kind: "add"; target: string; tween: GsapTweenSpec }
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
| { kind: "remove"; animationId: string };
/**
* Shadow a GSAP tween mutation (add / set / remove). The server's animationId
* shares the SDK's id-space (both derive `targetSelector-method-position` from
* the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
*
* Parity via the now-populated ElementSnapshot.animationIds:
* add → the returned tween id is present on the target element
* remove → the id is gone from every element
* set → existence only (the SDK exposes no per-tween property reader; value
* fidelity would need serialize()-script round-trip diffing).
*/
export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
const op: EditOp =
gsapOp.kind === "add"
? { type: "addGsapTween", target: gsapOp.target, tween: gsapOp.tween }
: gsapOp.kind === "set"
? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
: { type: "removeGsapTween", animationId: gsapOp.animationId };
// fallow-ignore-next-line complexity
runShadowEditOp(session, op, "gsap", () => {
let newId: string | undefined;
session.batch(() => {
if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
else session.dispatch(op);
});
if (gsapOp.kind === "add") {
const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
if (!newId || !onTarget.includes(newId)) {
return [
{
kind: "value_mismatch",
hfId: gsapOp.target,
property: "animationIds",
expected: newId ?? "non-empty",
actual: onTarget.join(",") || null,
},
];
}
} else if (gsapOp.kind === "remove") {
const stillPresent = session
.getElements()
.some((el) => el.animationIds.includes(gsapOp.animationId));
if (stillPresent) {
return [
{
kind: "value_mismatch",
hfId: gsapOp.animationId,
property: "animationIds",
expected: "removed",
actual: "present",
},
];
}
}
return [];
});
}
// GSAP value-fidelity diff lives in its own module to keep this file under the
// 600-line studio cap; re-exported here so the shadow surface stays in one place.
export {
gsapFidelityMismatches,
resolveGsapFidelityArgs,
runShadowGsapFidelity,
} from "./sdkShadowGsapFidelity";
@@ -1,296 +0,0 @@
/**
* GSAP value-fidelity shadow (serialize round-trip diff). Split out of
* sdkShadow.ts to keep that file under the 600-line studio cap.
*
* Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
* that its VALUES (duration / ease / position / properties) match the server.
* The SDK exposes no per-tween property reader, so we compare the two writers'
* output: apply the same op to a fresh SDK doc opened from the server's pre-op
* file, then structurally diff the SDK's GSAP script against the server's
* resulting script. Both are re-parsed, so formatting/whitespace differences
* never produce false positives — only real value drift does.
*/
import { openComposition } from "@hyperframes/sdk";
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import { relEqual } from "./sdkShadowNumeric";
import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
// Marker set must match document.ts extractGsapScript so both pick the same
// <script> from any given composition.
function isGsapScriptBody(body: string): boolean {
return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
}
export function extractGsapScript(html: string): string | null {
// Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
// before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
if (!scripts) return null;
for (const block of scripts) {
const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
if (isGsapScriptBody(body)) return body;
}
return null;
}
function posKey(position: unknown): string {
if (typeof position === "number") return String(position);
const n = Number(position);
return Number.isNaN(n) ? String(position) : String(n);
}
// Key a tween by its RESOLVED target element (not raw selector) + method +
// position. The SDK writer emits [data-hf-id="X"] selectors while the server
// emits class/other selectors for the SAME element; keying by resolved element
// matches them so the diff compares values instead of flagging present/absent.
//
// ponytail: one-tween-per-(element, method, position) assumption — coincident
// tweens (same element+method+position, different props) collapse, last wins,
// so the diff under-reports them. Props can't go in the key (a matched pair
// must share a key for the field-diff to run; raw props would split real value
// drift into present/absent). Not seen in studio-emitted templates; add a
// property-NAME hash to the key if coincident tweens show up in the wild.
function tweenKey(anim: GsapAnimation, resolveSelector?: (sel: string) => string): string {
const sel = resolveSelector ? resolveSelector(anim.targetSelector) : anim.targetSelector;
return `${sel}|${anim.method}|${posKey(anim.position)}`;
}
function animByKey(
script: string,
resolveSelector?: (sel: string) => string,
): Map<string, GsapAnimation> {
const map = new Map<string, GsapAnimation>();
const parsed = parseGsapScriptAcorn(script);
for (const anim of parsed.animations) map.set(tweenKey(anim, resolveSelector), 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.
// Coerce string operands to numbers, then compare with the shared relative
// epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
// isn't flagged as drift while a real 2 vs 1 still is.
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;
if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
return false;
}
return relEqual(na, nb);
}
function canonicalProps(obj: Record<string, unknown> | undefined): string {
if (!obj) return "{}";
const out: Record<string, unknown> = {};
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. Tweens are matched by resolved target
* element + method + position (see tweenKey), so the SDK's [data-hf-id]
* selectors and the server's class selectors for the same element don't
* false-flag present/absent. Reports a tween present in one but not the other,
* and per-field value drift (duration, ease, properties, fromProperties).
* Comparison is canonical so writer formatting differences don't register.
*
* Pass resolveSelector (selector → canonical element id) to enable the
* element-based matching; without it, matching falls back to raw selector.
*/
// fallow-ignore-next-line complexity
export function gsapFidelityMismatches(
sdkScript: string,
serverScript: string,
resolveSelector?: (sel: string) => string,
): SdkShadowMismatch[] {
const sdk = animByKey(sdkScript, resolveSelector);
const server = animByKey(serverScript, resolveSelector);
const mismatches: SdkShadowMismatch[] = [];
const keys = new Set([...sdk.keys(), ...server.keys()]);
for (const key of keys) {
const a = sdk.get(key);
const b = server.get(key);
if (!a || !b) {
mismatches.push({
kind: "value_mismatch",
hfId: key,
property: "tween",
expected: b ? "present" : "absent",
actual: a ? "present" : "absent",
});
continue;
}
// method + position are part of the key (already equal); compare values.
const fields: Array<[string, unknown, unknown, boolean]> = [
["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: key,
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 };
}
// Resolve a CSS selector to a canonical element key using the pre-op document,
// so tweens that target the same element via different selectors
// ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
//
// The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
// selector for the SAME element. Keying both forms to the same node prevents a
// false present/absent mismatch. Resolution order, for whatever element the
// selector matches:
// 1. data-hf-id present → "hfid:<id>" (the common, stable case)
// 2. no data-hf-id → "node:<n>" (per-document node index; identical
// regardless of which selector form found the node, so .x and [data-hf-id]
// pointing at the same attribute-less node still collapse)
// 3. selector resolves to no node / parse error / no DOM → the raw selector
// (last resort; only diverges when the two writers genuinely target
// different — or unresolvable — nodes, which is real drift to surface)
// The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
// collide with a raw-selector fallback.
//
// ponytail: first-match heuristic — querySelector returns the FIRST match, so an
// ambiguous selector (e.g. .x shared by two elements) may map to a different
// node than the SDK side's [data-hf-id] target and still flag present/absent.
// Safe for studio templates (one tween per element); upgrade to querySelectorAll
// + uniqueness check if ambiguous selectors appear.
export function makeSelectorResolver(html: string): (sel: string) => string {
let doc: Document | null = null;
try {
doc = new DOMParser().parseFromString(html, "text/html");
} catch {
doc = null;
}
// Stable per-node index so an attribute-less element keys identically no
// matter which selector form (class vs id vs [data-hf-id]) resolved it.
const nodeKeys = new WeakMap<Element, string>();
let nextNode = 0;
const keyForNode = (el: Element): string => {
const hfId = el.getAttribute("data-hf-id");
if (hfId != null && hfId !== "") return `hfid:${hfId}`;
const existing = nodeKeys.get(el);
if (existing != null) return existing;
const key = `node:${nextNode++}`;
nodeKeys.set(el, key);
return key;
};
return (sel) => {
if (!doc) return sel;
try {
const el = doc.querySelector(sel);
return el ? keyForNode(el) : sel;
} catch {
return sel;
}
};
}
/**
* 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<void> {
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,
makeSelectorResolver(beforeHtml),
);
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,
});
}
}
@@ -1,265 +0,0 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import {
resolveKeyframeIndexByPercentage,
keyframeOpToEditOp,
gsapKeyframeFidelityMismatches,
runShadowGsapKeyframeFidelity,
type ShadowKeyframeOp,
} from "./sdkShadowGsapKeyframe";
import { runShadowDispatch } from "./sdkShadow";
import type { PatchOperation } from "./sourcePatcher";
// Capture sdk_shadow_dispatch telemetry.
const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
vi.mock("./studioTelemetry", () => ({
trackStudioEvent: (event: string, props: Record<string, unknown>) =>
trackedEvents.push({ event, props }),
}));
// STUDIO_SDK_SHADOW_ENABLED defaults true (no env override in test), so the
// runners are active here without mocking the availability module.
beforeEach(() => {
trackedEvents.length = 0;
});
const lastShadow = () =>
trackedEvents.filter((e) => e.event === "sdk_shadow_dispatch").at(-1)?.props;
const ANIM_ID = "#hero-to-0-position";
function gsapHtml(scriptBody: string): string {
return /* html */ `<!DOCTYPE html><html><body>
<div data-hf-id="hf-hero" id="hero" class="clip">x</div>
<script>
${scriptBody}
window.__timelines = [tl];
</script>
</body></html>`;
}
const KF_SCRIPT = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
// A script body string (not full HTML) for the index-resolution helpers.
const KF_SCRIPT_BODY = KF_SCRIPT;
const DUP_SCRIPT_BODY = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 100 }, "50%": { x: 150 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
describe("resolveKeyframeIndexByPercentage", () => {
it("resolves a unique percentage to its 0-based index", () => {
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
keyframeIndex: 1,
});
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 100)).toEqual({
keyframeIndex: 2,
});
});
it("matches within ~0.001 tolerance", () => {
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 50.0005).keyframeIndex).toBe(
1,
);
});
it("returns null with not_found when no percentage matches", () => {
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, ANIM_ID, 33)).toEqual({
keyframeIndex: null,
reason: "not_found",
});
});
it("returns null with no_keyframes for an unknown animation", () => {
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, "#nope-to-0", 50)).toEqual({
keyframeIndex: null,
reason: "no_keyframes",
});
});
it("returns null with no_keyframes when script is empty", () => {
expect(resolveKeyframeIndexByPercentage(null, ANIM_ID, 50).reason).toBe("no_keyframes");
});
it("no-ops on ambiguity (duplicate-percentage keyframes — PR #1498 landmine)", () => {
expect(resolveKeyframeIndexByPercentage(DUP_SCRIPT_BODY, ANIM_ID, 50)).toEqual({
keyframeIndex: null,
reason: "ambiguous",
});
});
// Regression: a from/fromTo tween's id may normalize to "-to-" on write, so a
// "-from-"/"-fromTo-" animationId must fall back to the converted id (matching
// the writer's locateAnimationWithFallback) — else the keyframe diff goes blind.
it("falls back from a -from- id to the -to- tween", () => {
const fromId = ANIM_ID.replace("-to-", "-from-");
expect(resolveKeyframeIndexByPercentage(KF_SCRIPT_BODY, fromId, 50)).toEqual({
keyframeIndex: 1,
});
});
});
describe("keyframeOpToEditOp", () => {
it("maps add → addGsapKeyframe with position = percentage", () => {
const op: ShadowKeyframeOp = {
kind: "add",
animationId: ANIM_ID,
percentage: 25,
properties: { x: 50 },
};
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
op: { type: "addGsapKeyframe", animationId: ANIM_ID, position: 25, value: { x: 50 } },
});
});
it("maps remove → removeGsapKeyframe with resolved index", () => {
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
expect(keyframeOpToEditOp(op, KF_SCRIPT_BODY)).toEqual({
op: { type: "removeGsapKeyframe", animationId: ANIM_ID, keyframeIndex: 1 },
});
});
it("returns null op + reason when remove percentage is ambiguous", () => {
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
expect(keyframeOpToEditOp(op, DUP_SCRIPT_BODY)).toEqual({ op: null, reason: "ambiguous" });
});
});
describe("gsapKeyframeFidelityMismatches", () => {
it("reports no mismatches when keyframe arrays match", () => {
expect(gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, KF_SCRIPT_BODY, ANIM_ID)).toEqual([]);
});
it("reports a keyframes mismatch when arrays diverge", () => {
const other = `
const tl = gsap.timeline({ paused: true });
tl.to("#hero", { keyframes: { "0%": { x: 0 }, "50%": { x: 999 }, "100%": { x: 200 } }, duration: 5 }, 0);`;
const mismatches = gsapKeyframeFidelityMismatches(KF_SCRIPT_BODY, other, ANIM_ID);
expect(mismatches.some((m) => m.property === "keyframes")).toBe(true);
});
});
describe("runShadowGsapKeyframeFidelity (add)", () => {
it("emits gsap_keyframe with a keyframes mismatch when SDK adds but server didn't", async () => {
const beforeHtml = gsapHtml(KF_SCRIPT);
// server script unchanged (server "failed" to add the 25% keyframe) → drift
const session = await openComposition(beforeHtml);
const serverScript = session
.serialize()
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
expect(serverScript).toBeTruthy();
const op: ShadowKeyframeOp = {
kind: "add",
animationId: ANIM_ID,
percentage: 25,
properties: { x: 50 },
};
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
const props = lastShadow();
expect(props?.op).toBe("gsap_keyframe");
expect(props?.dispatched).toBe(true);
expect(props?.mismatchCount).toBe(1);
});
it("emits dispatched:true mismatchCount:0 when SDK and server agree", async () => {
const beforeHtml = gsapHtml(KF_SCRIPT);
// Build the server's resulting script by applying the same op via the SDK.
const serverSession = await openComposition(beforeHtml);
serverSession.batch(() =>
serverSession.dispatch({
type: "addGsapKeyframe",
animationId: ANIM_ID,
position: 25,
value: { x: 50 },
}),
);
const serverScript = serverSession
.serialize()
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
const op: ShadowKeyframeOp = {
kind: "add",
animationId: ANIM_ID,
percentage: 25,
properties: { x: 50 },
};
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
const props = lastShadow();
expect(props?.op).toBe("gsap_keyframe");
expect(props?.dispatched).toBe(true);
expect(props?.mismatchCount).toBe(0);
});
});
describe("runShadowGsapKeyframeFidelity (remove)", () => {
it("no-ops with reason when remove percentage is ambiguous", async () => {
const beforeHtml = gsapHtml(DUP_SCRIPT_BODY);
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
await runShadowGsapKeyframeFidelity(beforeHtml, op, "non-empty-server-script gsap");
const props = lastShadow();
expect(props?.op).toBe("gsap_keyframe");
expect(props?.dispatched).toBe(false);
expect(props?.reason).toBe("ambiguous");
});
it("dispatches a resolved remove and diffs", async () => {
const beforeHtml = gsapHtml(KF_SCRIPT);
const serverSession = await openComposition(beforeHtml);
serverSession.batch(() =>
serverSession.dispatch({
type: "removeGsapKeyframe",
animationId: ANIM_ID,
keyframeIndex: 1,
}),
);
const serverScript = serverSession
.serialize()
.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1];
const op: ShadowKeyframeOp = { kind: "remove", animationId: ANIM_ID, percentage: 50 };
await runShadowGsapKeyframeFidelity(beforeHtml, op, serverScript);
const props = lastShadow();
expect(props?.op).toBe("gsap_keyframe");
expect(props?.dispatched).toBe(true);
expect(props?.mismatchCount).toBe(0);
});
});
describe("runShadowGsapKeyframeFidelity (guards)", () => {
it("skips when there is no server script", async () => {
const op: ShadowKeyframeOp = {
kind: "add",
animationId: ANIM_ID,
percentage: 25,
properties: { x: 50 },
};
await runShadowGsapKeyframeFidelity(gsapHtml(KF_SCRIPT), op, null);
expect(lastShadow()).toBeUndefined();
});
});
describe("runShadowDispatch unmapped-type guard", () => {
const ELEMENT_HTML = /* html */ `<!DOCTYPE html><html><body>
<div data-hf-id="hf-box" style="color: red;">Hi</div>
</body></html>`;
it("emits unmapped_type when a PatchOperation type isn't mapped", async () => {
const session = await openComposition(ELEMENT_HTML);
// PatchOperation.type is a closed union today; cast to exercise the defensive
// guard for a future unmapped type.
const ops = [{ type: "future-op", property: "x", value: "1" } as unknown as PatchOperation];
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
const props = lastShadow();
expect(props?.op).toBe("property");
expect(props?.dispatched).toBe(false);
expect(props?.reason).toBe("unmapped_type");
expect(props?.type).toBe("future-op");
});
it("dispatches normally for known PatchOperation types", async () => {
const session = await openComposition(ELEMENT_HTML);
const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "#00f" }];
runShadowDispatch(session, { hfId: "hf-box" } as never, ops);
const props = lastShadow();
expect(props?.dispatched).toBe(true);
expect(props?.reason).toBeUndefined();
});
});
@@ -1,257 +0,0 @@
/**
* GSAP keyframe-op shadow (serialize round-trip diff). New module for the Stage 7
* shadow-parity push — kept out of sdkShadow.ts / sdkShadowGsapFidelity.ts so the
* shared files stay untouched (only additive imports) and the studio 600-line cap
* holds.
*
* Unlike tweens, the SDK exposes NO keyframe reader on ElementSnapshot, so there
* is no existence-parity path here. Instead we compare the two writers' output:
* open a fresh SDK doc from the server's pre-op file, dispatch the equivalent
* keyframe op, serialize, and diff the SDK's GSAP script against the server's
* resulting script.
*
* gsapFidelityMismatches (reused) matches tweens by resolved target element +
* method + position and diffs tween-level fields — but it does NOT look inside a
* tween's `keyframes` array. Keyframe drift therefore needs a dedicated diff,
* layered on top of the reused tween-level diff, matched by the GSAP animation id.
*
* SDK mapping (main, pre PR #1498 percentage-variant):
* add → addGsapKeyframe{animationId, position: percentage, value: properties}
* remove → removeGsapKeyframe{animationId, keyframeIndex} — the studio op is
* percentage-based, so we resolve percentage → index against the pre-op
* script (KF_PERCENT_TOLERANCE, aligned with the writer ~0.001) and
* no-op on ambiguity (duplicate-percentage keyframes can't be told
* apart by percentage — landmine from PR #1498).
*/
import { openComposition } from "@hyperframes/sdk";
import type { EditOp } from "@hyperframes/sdk";
import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
import { trackStudioEvent } from "./studioTelemetry";
import type { SdkShadowMismatch } from "./sdkShadow";
import {
extractGsapScript,
gsapFidelityMismatches,
makeSelectorResolver,
} from "./sdkShadowGsapFidelity";
// Match the GSAP writer's percentage equality tolerance so a remove resolves to
// the same keyframe the server would pick (writer rounds to ~3 decimals).
const KF_PERCENT_TOLERANCE = 0.001;
export type ShadowKeyframeOp =
| {
kind: "add";
animationId: string;
percentage: number;
properties: Record<string, number | string>;
}
| { kind: "remove"; animationId: string; percentage: number };
// ─── percentage → SDK op mapping ──────────────────────────────────────────────
function findAnimationKeyframes(
script: string,
animationId: string,
): GsapPercentageKeyframe[] | null {
const parsed = parseGsapScriptAcorn(script);
// Match the writer's locateAnimationWithFallback (gsapParser.ts): a from/fromTo
// tween's derived id may be normalized to "-to-" on write, so fall back to the
// converted id when the exact one isn't found — otherwise the keyframe diff
// goes blind (both scripts resolve null → falsely "clean") on converted tweens.
const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-");
const anim =
parsed.animations.find((a) => a.id === animationId) ??
parsed.animations.find((a) => a.id === convertedId);
return anim?.keyframes?.keyframes ?? null;
}
export interface KeyframeRemoveResolution {
/** Resolved 0-based index, or null when it can't be safely resolved. */
keyframeIndex: number | null;
/** Why no index — for telemetry when keyframeIndex is null. */
reason?: "no_keyframes" | "not_found" | "ambiguous";
}
/**
* Resolve a percentage-based remove to a keyframe index against the pre-op
* script. Returns null index (with a reason) when there are no keyframes, the
* percentage matches none, or — per the PR #1498 landmine — more than one
* keyframe shares the percentage (can't be disambiguated by percentage alone).
* Pure + exported so the mapping is unit-testable without an SDK session.
*/
export function resolveKeyframeIndexByPercentage(
script: string | null | undefined,
animationId: string,
percentage: number,
): KeyframeRemoveResolution {
if (!script) return { keyframeIndex: null, reason: "no_keyframes" };
const kfs = findAnimationKeyframes(script, animationId);
if (!kfs || kfs.length === 0) return { keyframeIndex: null, reason: "no_keyframes" };
const matches: number[] = [];
for (let i = 0; i < kfs.length; i++) {
if (Math.abs(kfs[i]?.percentage - percentage) <= KF_PERCENT_TOLERANCE) matches.push(i);
}
if (matches.length === 0) return { keyframeIndex: null, reason: "not_found" };
if (matches.length > 1) return { keyframeIndex: null, reason: "ambiguous" };
return { keyframeIndex: matches[0] };
}
/**
* Map a studio keyframe op to the SDK EditOp. For a remove this needs the pre-op
* script to resolve percentage → index; returns null (with a reason) when the
* index can't be safely resolved so the caller can emit a no-op-with-reason
* event instead of dispatching the wrong keyframe.
*/
export function keyframeOpToEditOp(
op: ShadowKeyframeOp,
beforeScript: string | null | undefined,
): { op: EditOp } | { op: null; reason: string } {
if (op.kind === "add") {
return {
op: {
type: "addGsapKeyframe",
animationId: op.animationId,
position: op.percentage,
value: op.properties,
},
};
}
const resolved = resolveKeyframeIndexByPercentage(beforeScript, op.animationId, op.percentage);
if (resolved.keyframeIndex === null) {
return { op: null, reason: resolved.reason ?? "unresolved" };
}
return {
op: {
type: "removeGsapKeyframe",
animationId: op.animationId,
keyframeIndex: resolved.keyframeIndex,
},
};
}
// ─── Keyframe-aware fidelity diff ─────────────────────────────────────────────
function canonicalKeyframe(kf: GsapPercentageKeyframe): string {
const props: Record<string, unknown> = {};
for (const key of Object.keys(kf.properties).sort()) {
const v = kf.properties[key];
props[key] =
typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
}
return JSON.stringify({ pct: Math.round(kf.percentage * 1000) / 1000, ease: kf.ease, props });
}
function canonicalKeyframes(kfs: GsapPercentageKeyframe[] | null): string {
if (!kfs) return "[]";
return JSON.stringify(
[...kfs].sort((a, b) => a.percentage - b.percentage).map(canonicalKeyframe),
);
}
/**
* Diff two GSAP scripts for a keyframe op: the reused tween-level diff PLUS a
* keyframe-array comparison for the targeted animation (which the tween-level
* diff doesn't inspect). Reports a `keyframes` value_mismatch when the SDK and
* server keyframe arrays diverge canonically.
*/
export function gsapKeyframeFidelityMismatches(
sdkScript: string,
serverScript: string,
animationId: string,
resolveSelector?: (sel: string) => string,
): SdkShadowMismatch[] {
const mismatches = gsapFidelityMismatches(sdkScript, serverScript, resolveSelector);
const sdkKfs = findAnimationKeyframes(sdkScript, animationId);
const serverKfs = findAnimationKeyframes(serverScript, animationId);
const sdkCanon = canonicalKeyframes(sdkKfs);
const serverCanon = canonicalKeyframes(serverKfs);
if (sdkCanon !== serverCanon) {
mismatches.push({
kind: "value_mismatch",
hfId: animationId,
property: "keyframes",
expected: serverCanon,
actual: sdkCanon,
});
}
return mismatches;
}
// ─── Telemetry runner ─────────────────────────────────────────────────────────
/**
* Shadow a GSAP keyframe op: open a fresh SDK doc from the server's pre-op file,
* apply the equivalent keyframe op, serialize, and diff against the server's
* resulting script. Emits sdk_shadow_dispatch op: "gsap_keyframe". Async,
* fire-and-forget; server stays authoritative. No-op when shadow is disabled.
*/
export async function runShadowGsapKeyframeFidelity(
beforeHtml: string | null | undefined,
op: ShadowKeyframeOp,
serverScript: string | null | undefined,
): Promise<void> {
if (!STUDIO_SDK_SHADOW_ENABLED) return;
// No server script to diff against → skip the (costly) openComposition.
if (!serverScript || !beforeHtml) return;
const beforeScript = extractGsapScript(beforeHtml);
const mapped = keyframeOpToEditOp(op, beforeScript);
if (mapped.op === null) {
// Ambiguous / not-found percentage: don't dispatch the wrong keyframe.
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_keyframe",
dispatched: false,
reason: mapped.reason,
mismatchCount: 0,
});
return;
}
const editOp = mapped.op;
try {
const session = await openComposition(beforeHtml);
const verdict = session.can(editOp);
if (!verdict.ok) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_keyframe",
dispatched: false,
reason: "cannot_dispatch",
code: verdict.code,
mismatchCount: 0,
});
return;
}
session.batch(() => session.dispatch(editOp));
const sdkScript = extractGsapScript(session.serialize());
if (sdkScript == null) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_keyframe",
dispatched: false,
reason: "no_sdk_script",
mismatchCount: 0,
});
return;
}
const mismatches = gsapKeyframeFidelityMismatches(
sdkScript,
serverScript,
op.animationId,
makeSelectorResolver(beforeHtml),
);
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_keyframe",
dispatched: true,
mismatchCount: mismatches.length,
mismatches: JSON.stringify(mismatches),
});
} catch (err) {
trackStudioEvent("sdk_shadow_dispatch", {
op: "gsap_keyframe",
dispatched: false,
reason: "fidelity_error",
error: String(err),
mismatchCount: 0,
});
}
}
@@ -1,11 +0,0 @@
/**
* Relative-epsilon numeric equality shared by the shadow diffs (timing parity +
* GSAP value fidelity). Both writers round-trip durations/positions through JS
* number formatting, so a value like 3.1 can read back as 3.0999999999999996.
* Treat values within 1e-6 * max(1, |a|, |b|) as equal — tight enough that a
* real 2 vs 1 (or 0.5 vs 0.49) still flags, loose enough to absorb float noise.
*/
export function relEqual(a: number, b: number): boolean {
if (a === b) return true;
return Math.abs(a - b) <= 1e-6 * Math.max(1, Math.abs(a), Math.abs(b));
}