@hyperframes/studio 0.6.101 → 0.6.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-CTiqZ7XQ.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-woy2HyV8.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.101",
3
+ "version": "0.6.103",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,9 +33,9 @@
33
33
  "@phosphor-icons/react": "^2.1.10",
34
34
  "bpm-detective": "^2.0.5",
35
35
  "mediabunny": "^1.45.3",
36
- "@hyperframes/core": "0.6.101",
37
- "@hyperframes/player": "0.6.101",
38
- "@hyperframes/sdk": "0.6.101"
36
+ "@hyperframes/core": "0.6.103",
37
+ "@hyperframes/player": "0.6.103",
38
+ "@hyperframes/sdk": "0.6.103"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/react": "19",
@@ -49,7 +49,7 @@
49
49
  "vite": "^6.4.2",
50
50
  "vitest": "^3.2.4",
51
51
  "zustand": "^5.0.0",
52
- "@hyperframes/producer": "0.6.101"
52
+ "@hyperframes/producer": "0.6.103"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "19",
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useCallback, useRef, useMemo } from "react";
2
2
  import { trackStudioRenderStart } from "../../telemetry/events";
3
+ import { getAnonymousId } from "../../telemetry/config";
3
4
 
4
5
  export interface RenderJob {
5
6
  id: string;
@@ -109,10 +110,15 @@ export function useRenderQueue(projectId: string | null) {
109
110
  format: string;
110
111
  resolution?: string;
111
112
  composition?: string;
113
+ telemetryDistinctId: string;
112
114
  } = {
113
115
  fps,
114
116
  quality,
115
117
  format,
118
+ // So the server-emitted render_complete/render_error is attributed to
119
+ // this browser user (same id studio_* events use), making the render
120
+ // funnel joinable. Matches studio_render_start fired just above.
121
+ telemetryDistinctId: getAnonymousId(),
116
122
  };
117
123
  if (resolution && resolution !== "auto") body.resolution = resolution;
118
124
  if (composition) body.composition = composition;
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
4
+ import {
5
+ clearKeyframeCacheForElement,
6
+ clearKeyframeCacheForFile,
7
+ updateKeyframeCacheFromParsed,
8
+ } from "./gsapKeyframeCacheHelpers";
9
+
10
+ const entry = (): KeyframeCacheEntry => ({
11
+ format: "percentage",
12
+ keyframes: [{ percentage: 0, properties: { x: 0 } }],
13
+ });
14
+
15
+ const seed = (key: string) => usePlayerStore.getState().setKeyframeCache(key, entry());
16
+ const cache = () => usePlayerStore.getState().keyframeCache;
17
+
18
+ const animWithKeyframes = (id: string): GsapAnimation => ({
19
+ id,
20
+ targetSelector: `#${id}`,
21
+ method: "to",
22
+ position: 0,
23
+ properties: {},
24
+ duration: 1,
25
+ resolvedStart: 0,
26
+ propertyGroup: "position",
27
+ keyframes: { format: "percentage", keyframes: [{ percentage: 50, properties: { x: 100 } }] },
28
+ });
29
+
30
+ beforeEach(() => {
31
+ usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
32
+ });
33
+
34
+ describe("clearKeyframeCacheForElement", () => {
35
+ it("drops the prefixed, index.html fallback, and bare key for a non-index source", () => {
36
+ seed("comp.html#box");
37
+ seed("index.html#box");
38
+ seed("box");
39
+
40
+ clearKeyframeCacheForElement("comp.html", "box");
41
+
42
+ expect(cache().has("comp.html#box")).toBe(false);
43
+ expect(cache().has("index.html#box")).toBe(false);
44
+ // The bare key is what PropertyPanel's keyframe nav reads (element.id), so
45
+ // it must be cleared too, not just the prefixed variants.
46
+ expect(cache().has("box")).toBe(false);
47
+ });
48
+
49
+ it("drops the prefixed and bare key for an index.html source", () => {
50
+ seed("index.html#hero");
51
+ seed("hero");
52
+
53
+ clearKeyframeCacheForElement("index.html", "hero");
54
+
55
+ expect(cache().has("index.html#hero")).toBe(false);
56
+ expect(cache().has("hero")).toBe(false);
57
+ });
58
+
59
+ it("leaves other elements' keys untouched", () => {
60
+ seed("index.html#box");
61
+ seed("box");
62
+ seed("index.html#other");
63
+ seed("other");
64
+
65
+ clearKeyframeCacheForElement("index.html", "box");
66
+
67
+ expect(cache().has("index.html#other")).toBe(true);
68
+ expect(cache().has("other")).toBe(true);
69
+ });
70
+ });
71
+
72
+ describe("clearKeyframeCacheForFile", () => {
73
+ it("clears the prefixed, fallback, and bare keys for every element of the file", () => {
74
+ seed("comp.html#a");
75
+ seed("index.html#a");
76
+ seed("a");
77
+ seed("comp.html#b");
78
+ seed("b");
79
+
80
+ clearKeyframeCacheForFile("comp.html");
81
+
82
+ for (const key of ["comp.html#a", "index.html#a", "a", "comp.html#b", "b"]) {
83
+ expect(cache().has(key)).toBe(false);
84
+ }
85
+ });
86
+
87
+ it("leaves entries that belong to a different source file", () => {
88
+ seed("comp.html#a");
89
+ seed("a");
90
+ seed("other.html#z");
91
+ seed("z");
92
+
93
+ clearKeyframeCacheForFile("comp.html");
94
+
95
+ expect(cache().has("other.html#z")).toBe(true);
96
+ expect(cache().has("z")).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("updateKeyframeCacheFromParsed", () => {
101
+ it("clears the bare key when the selected element no longer has keyframes", () => {
102
+ // Element previously had keyframes, so a bare entry exists (writes set both).
103
+ seed("index.html#box");
104
+ seed("box");
105
+
106
+ // A mutation leaves #box without any keyframes in the parsed animations.
107
+ updateKeyframeCacheFromParsed([], "index.html", "box", {});
108
+
109
+ expect(cache().has("index.html#box")).toBe(false);
110
+ // Without the bare-key clear this assertion fails: the stale entry survives
111
+ // and PropertyPanel keeps rendering the removed keyframes.
112
+ expect(cache().has("box")).toBe(false);
113
+ });
114
+
115
+ it("still writes the bare key for elements that have keyframes", () => {
116
+ updateKeyframeCacheFromParsed([animWithKeyframes("hero")], "index.html", "hero", {});
117
+
118
+ expect(cache().has("index.html#hero")).toBe(true);
119
+ expect(cache().has("hero")).toBe(true);
120
+ });
121
+ });
@@ -67,8 +67,54 @@ export function updateKeyframeCacheFromParsed(
67
67
  (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
68
68
  selectionId;
69
69
  if (targetId && !idsWithKeyframes.has(targetId)) {
70
- setKeyframeCache(`${targetPath}#${targetId}`, undefined);
71
- if (targetPath !== "index.html") setKeyframeCache(`index.html#${targetId}`, undefined);
70
+ clearKeyframeCacheForElement(targetPath, targetId);
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Clear every keyframe-cache key variant written for an element: the
76
+ * source-prefixed key, the index.html fallback, and the bare element id.
77
+ * Writes set all three (see updateKeyframeCacheFromParsed and
78
+ * usePopulateKeyframeCacheForFile). PropertyPanel's keyframe nav reads the bare
79
+ * id directly (`element.id`), and other consumers (timeline diamonds, the
80
+ * preview overlay) fall back to the bare id when an element has no
81
+ * source-prefixed key — so a clear that drops only the prefixed keys leaves the
82
+ * bare entry behind and those readers keep showing keyframes the element no
83
+ * longer has. Each delete is guarded by `has` so an absent key doesn't allocate
84
+ * a new cache map and re-render every subscriber.
85
+ */
86
+ export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
87
+ const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
88
+ const keys =
89
+ sourceFile === "index.html"
90
+ ? [`index.html#${elementId}`, elementId]
91
+ : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
92
+ for (const key of keys) {
93
+ if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Clear every cached element of `sourceFile` before a full re-scan repopulates
99
+ * it. Collects the element ids that currently have a prefixed or index.html
100
+ * fallback key for the file and drops each through clearKeyframeCacheForElement
101
+ * so the bare key goes too — an element whose keyframes were removed (and so is
102
+ * absent from the re-scan) leaves no stale bare entry behind.
103
+ */
104
+ export function clearKeyframeCacheForFile(sourceFile: string): void {
105
+ const { keyframeCache } = usePlayerStore.getState();
106
+ const sfPrefix = `${sourceFile}#`;
107
+ const fallbackPrefix = "index.html#";
108
+ const ids = new Set<string>();
109
+ for (const key of keyframeCache.keys()) {
110
+ const matchesFile =
111
+ key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
112
+ if (!matchesFile) continue;
113
+ const hashIdx = key.indexOf("#");
114
+ if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1));
115
+ }
116
+ for (const id of ids) {
117
+ clearKeyframeCacheForElement(sourceFile, id);
72
118
  }
73
119
  }
74
120
 
@@ -2,6 +2,7 @@ import type { ParsedGsap } from "@hyperframes/core/gsap-parser";
2
2
  import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
+ import type { ShadowGsapOp } from "../utils/sdkShadow";
5
6
 
6
7
  export interface MutationResult {
7
8
  ok: boolean;
@@ -18,6 +19,8 @@ export interface CommitMutationOptions {
18
19
  softReload?: boolean;
19
20
  skipReload?: boolean;
20
21
  beforeReload?: () => void;
22
+ /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
23
+ shadowGsapOp?: ShadowGsapOp;
21
24
  }
22
25
 
23
26
  export type CommitMutation = (
@@ -1,8 +1,8 @@
1
1
  import { useCallback } from "react";
2
- import type { Composition, GsapTweenSpec } from "@hyperframes/sdk";
2
+ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { roundTo3 } from "../utils/rounding";
5
- import { runShadowGsapTween } from "../utils/sdkShadow";
5
+ import { runShadowGsapTween, type ShadowGsapOp } from "../utils/sdkShadow";
6
6
  import {
7
7
  assignGsapTargetAutoIdIfNeeded,
8
8
  ensureElementAddressable,
@@ -33,27 +33,34 @@ export function useGsapAnimationOps({
33
33
  animationId: string,
34
34
  updates: { duration?: number; ease?: string; position?: number },
35
35
  ) => {
36
+ // Shadow op (server animationId shares the SDK id-space): existence via
37
+ // runShadowGsapTween (live session) + value fidelity via the chokepoint.
38
+ const shadowGsapOp: ShadowGsapOp = {
39
+ kind: "set",
40
+ animationId,
41
+ properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
42
+ };
36
43
  commitMutationSafely(
37
44
  selection,
38
45
  { type: "update-meta", animationId, updates },
39
- {
40
- label: "Edit GSAP animation",
41
- coalesceKey: `gsap:${animationId}:meta`,
42
- },
46
+ { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
43
47
  );
48
+ if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
44
49
  },
45
- [commitMutationSafely],
50
+ [commitMutationSafely, sdkSession],
46
51
  );
47
52
 
48
53
  const deleteGsapAnimation = useCallback(
49
54
  (selection: DomEditSelection, animationId: string) => {
55
+ const shadowGsapOp: ShadowGsapOp = { kind: "remove", animationId };
50
56
  commitMutationSafely(
51
57
  selection,
52
58
  { type: "delete", animationId, stripStudioEdits: true },
53
- { label: "Delete GSAP animation" },
59
+ { label: "Delete GSAP animation", shadowGsapOp },
54
60
  );
61
+ if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
55
62
  },
56
- [commitMutationSafely],
63
+ [commitMutationSafely, sdkSession],
57
64
  );
58
65
 
59
66
  const deleteAllForSelector = useCallback(
@@ -103,6 +110,26 @@ export function useGsapAnimationOps({
103
110
  fromTo: { x: 0, y: 0, opacity: 1 },
104
111
  };
105
112
 
113
+ // Shadow op (server stays authoritative). "set" has no SDK method, so it
114
+ // is not shadowed; otherwise: existence via runShadowGsapTween (live) +
115
+ // value fidelity via the chokepoint (shadowGsapOp in options).
116
+ const shadowGsapOp: ShadowGsapOp | undefined =
117
+ selection.hfId && method !== "set"
118
+ ? {
119
+ kind: "add",
120
+ target: selection.hfId,
121
+ tween: {
122
+ method,
123
+ position,
124
+ duration,
125
+ ease: "power2.out",
126
+ ...(method === "fromTo"
127
+ ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
128
+ : { properties: toDefaults[method] ?? { opacity: 1 } }),
129
+ },
130
+ }
131
+ : undefined;
132
+
106
133
  await commitMutation(
107
134
  selection,
108
135
  {
@@ -115,25 +142,10 @@ export function useGsapAnimationOps({
115
142
  properties: toDefaults[method] ?? { opacity: 1 },
116
143
  fromProperties: method === "fromTo" ? { opacity: 0 } : undefined,
117
144
  },
118
- { label: `Add GSAP ${method} animation` },
145
+ { label: `Add GSAP ${method} animation`, shadowGsapOp },
119
146
  );
120
147
 
121
- // Shadow: dispatch the equivalent addGsapTween to the SDK (server stays
122
- // authoritative). "set" has no SDK method, so it is not shadowed.
123
- // ponytail: only add is shadowed — delete/update key on the server's
124
- // animationId, which doesn't resolve in the SDK's independent id-space.
125
- if (sdkSession && selection.hfId && method !== "set") {
126
- const tween: GsapTweenSpec = {
127
- method,
128
- position,
129
- duration,
130
- ease: "power2.out",
131
- ...(method === "fromTo"
132
- ? { fromProperties: { opacity: 0 }, toProperties: toDefaults[method] }
133
- : { properties: toDefaults[method] ?? { opacity: 1 } }),
134
- };
135
- runShadowGsapTween(sdkSession, { kind: "add", target: selection.hfId, tween });
136
- }
148
+ if (sdkSession && shadowGsapOp) runShadowGsapTween(sdkSession, shadowGsapOp);
137
149
  },
138
150
  [activeCompPath, commitMutation, projectIdRef, showToast, sdkSession],
139
151
  );
@@ -2,6 +2,7 @@ import { useCallback } from "react";
2
2
  import { findUnsafeMutationValues } from "@hyperframes/core/studio-api/finite-mutation";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import { applySoftReload } from "../utils/gsapSoftReload";
5
+ import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
5
6
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
6
7
  import {
7
8
  GsapMutationHttpError,
@@ -67,6 +68,21 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
67
68
  }
68
69
  if (result.changed === false) return;
69
70
  domEditSaveTimestampRef.current = Date.now();
71
+ // Shadow value fidelity: diff the SDK's GSAP writer output against the
72
+ // server's, from the same pre-op file. Fire-and-forget; server authoritative.
73
+ // Only meta-level ops carry shadowGsapOp today (add / update-meta / delete via
74
+ // useGsapAnimationOps). Per-property and keyframe handlers (useGsapPropertyDebounce,
75
+ // useGsapKeyframeOps) intentionally don't synthesize one yet — deferred follow-up.
76
+ // scriptText is null when the composition has no GSAP script; nothing to diff.
77
+ const fidelityArgs = resolveGsapFidelityArgs(
78
+ sdkSession,
79
+ options.shadowGsapOp,
80
+ result.before,
81
+ result.scriptText,
82
+ );
83
+ if (fidelityArgs) {
84
+ void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
85
+ }
70
86
  if (result.before != null && result.after != null) {
71
87
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
72
88
  }
@@ -80,7 +96,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
80
96
  reloadPreview();
81
97
  }
82
98
  onCacheInvalidate();
83
- }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast]);
99
+ }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
84
100
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
85
101
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
86
102
  const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
@@ -3,6 +3,10 @@ import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/
3
3
  import type { GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser";
4
4
  import { usePlayerStore } from "../player/store/playerStore";
5
5
  import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge";
6
+ import {
7
+ clearKeyframeCacheForElement,
8
+ clearKeyframeCacheForFile,
9
+ } from "./gsapKeyframeCacheHelpers";
6
10
  import { PROPERTY_DEFAULTS, toAbsoluteTime } from "./gsapShared";
7
11
 
8
12
  function deduplicateKeyframes(keyframes: GsapPercentageKeyframe[]): GsapPercentageKeyframe[] {
@@ -301,10 +305,7 @@ export function useGsapAnimationsForElement(
301
305
  if (kf.easeEach) easeEach = kf.easeEach;
302
306
  }
303
307
  if (allKeyframes.length === 0) {
304
- const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
305
- if (keyframeCache.has(`${sourceFile}#${elementId}`)) {
306
- setKeyframeCache(`${sourceFile}#${elementId}`, undefined);
307
- }
308
+ clearKeyframeCacheForElement(sourceFile, elementId);
308
309
  return;
309
310
  }
310
311
  const dedupedKeyframes = deduplicateKeyframes(allKeyframes);
@@ -358,14 +359,11 @@ export function usePopulateKeyframeCacheForFile(
358
359
  const sf = sourceFile;
359
360
  fetchParsedAnimations(projectId, sf).then((parsed) => {
360
361
  if (!parsed) return;
361
- const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
362
- const sfPrefix = `${sf}#`;
363
- const fallbackPrefix = "index.html#";
364
- for (const key of keyframeCache.keys()) {
365
- if (key.startsWith(sfPrefix) || (sf !== "index.html" && key.startsWith(fallbackPrefix))) {
366
- setKeyframeCache(key, undefined);
367
- }
368
- }
362
+ const { setKeyframeCache } = usePlayerStore.getState();
363
+ // Drop the file's stale entries (including the bare keys consumers read)
364
+ // before repopulating, so an element whose keyframes were removed and is
365
+ // absent from this scan doesn't keep showing diamonds.
366
+ clearKeyframeCacheForFile(sf);
369
367
  const { elements } = usePlayerStore.getState();
370
368
  const mergedByElement = new Map<string, GsapKeyframesData>();
371
369
  for (const anim of parsed.animations) {
@@ -1,20 +1,7 @@
1
1
  import { useCallback } from "react";
2
2
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
3
3
  import { getStudioSaveErrorMessage, trackStudioSaveFailure } from "../utils/studioSaveDiagnostics";
4
-
5
- type CommitMutationOptions = {
6
- label: string;
7
- coalesceKey?: string;
8
- softReload?: boolean;
9
- skipReload?: boolean;
10
- beforeReload?: () => void;
11
- };
12
-
13
- type CommitMutation = (
14
- selection: DomEditSelection,
15
- mutation: Record<string, unknown>,
16
- options: CommitMutationOptions,
17
- ) => Promise<void>;
4
+ import type { CommitMutation, CommitMutationOptions } from "./gsapScriptCommitTypes";
18
5
 
19
6
  type TrackGsapSaveFailure = (
20
7
  error: unknown,
@@ -4,8 +4,12 @@ import {
4
4
  runShadowDelete,
5
5
  runShadowTiming,
6
6
  runShadowGsapTween,
7
+ runShadowGsapFidelity,
8
+ gsapFidelityMismatches,
9
+ resolveGsapFidelityArgs,
7
10
  SdkShadowMismatch,
8
11
  } from "./sdkShadow";
12
+ import type { ShadowGsapOp } from "./sdkShadow";
9
13
  import type { PatchOperation } from "./sourcePatcher";
10
14
  import { openComposition } from "@hyperframes/sdk";
11
15
 
@@ -219,13 +223,24 @@ describe("runShadowTiming", () => {
219
223
  });
220
224
 
221
225
  describe("runShadowGsapTween", () => {
222
- it("dispatches add against a real timeline and reports success", async () => {
226
+ it("add reports success and the new tween lands on the target's animationIds", async () => {
223
227
  const session = await openComposition(GSAP_HTML);
228
+ const before = session.getElement("hf-box")?.animationIds.length ?? 0;
224
229
  runShadowGsapTween(session, {
225
230
  kind: "add",
226
231
  target: "hf-box",
227
232
  tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
228
233
  });
234
+ expect(session.getElement("hf-box")!.animationIds.length).toBe(before + 1);
235
+ expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
236
+ });
237
+
238
+ it("remove drops the tween from animationIds and reports parity", async () => {
239
+ const session = await openComposition(GSAP_HTML);
240
+ const animationId = session.getElement("hf-box")?.animationIds[0];
241
+ expect(animationId).toBeDefined();
242
+ runShadowGsapTween(session, { kind: "remove", animationId: animationId! });
243
+ expect(session.getElement("hf-box")?.animationIds ?? []).not.toContain(animationId);
229
244
  expect(lastShadow()).toMatchObject({ op: "gsap", dispatched: true, mismatchCount: 0 });
230
245
  });
231
246
 
@@ -244,3 +259,118 @@ describe("runShadowGsapTween", () => {
244
259
  });
245
260
  });
246
261
  });
262
+
263
+ const SCRIPT_A = `var tl = gsap.timeline({ paused: true });
264
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0.2);
265
+ window.__timelines["t"] = tl;`;
266
+
267
+ describe("gsapFidelityMismatches", () => {
268
+ it("returns no mismatches for identical scripts", () => {
269
+ expect(gsapFidelityMismatches(SCRIPT_A, SCRIPT_A)).toEqual([]);
270
+ });
271
+
272
+ it("flags a per-field value drift (duration)", () => {
273
+ const drifted = SCRIPT_A.replace("duration: 0.5", "duration: 0.9");
274
+ const mismatches = gsapFidelityMismatches(drifted, SCRIPT_A);
275
+ expect(mismatches.some((m) => m.property === "duration")).toBe(true);
276
+ });
277
+
278
+ it("flags a tween present in one script but not the other", () => {
279
+ const empty = `var tl = gsap.timeline({ paused: true });
280
+ window.__timelines["t"] = tl;`;
281
+ const mismatches = gsapFidelityMismatches(empty, SCRIPT_A);
282
+ expect(mismatches.some((m) => m.property === "tween")).toBe(true);
283
+ });
284
+
285
+ it("does NOT flag property key-order differences (canonical compare)", () => {
286
+ const ab = `var tl = gsap.timeline({ paused: true });
287
+ tl.to("[data-hf-id=\\"hf-box\\"]", { x: 10, y: 20, duration: 0.5 }, 0);
288
+ window.__timelines["t"] = tl;`;
289
+ const ba = `var tl = gsap.timeline({ paused: true });
290
+ tl.to("[data-hf-id=\\"hf-box\\"]", { y: 20, x: 10, duration: 0.5 }, 0);
291
+ window.__timelines["t"] = tl;`;
292
+ expect(gsapFidelityMismatches(ab, ba)).toEqual([]);
293
+ });
294
+
295
+ it("does NOT flag number-vs-string-equivalent property values", () => {
296
+ const numeric = `var tl = gsap.timeline({ paused: true });
297
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 0.5 }, 0);
298
+ window.__timelines["t"] = tl;`;
299
+ const stringy = `var tl = gsap.timeline({ paused: true });
300
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: "1", duration: 0.5 }, 0);
301
+ window.__timelines["t"] = tl;`;
302
+ expect(gsapFidelityMismatches(numeric, stringy)).toEqual([]);
303
+ });
304
+ });
305
+
306
+ describe("runShadowGsapFidelity", () => {
307
+ const BEFORE_HTML = `<div data-hf-id="hf-stage" data-hf-root style="width:1280px;height:720px">
308
+ <div data-hf-id="hf-box" style="opacity:0"></div>
309
+ <script>var tl = gsap.timeline({ paused: true });
310
+ window.__timelines["t"] = tl;</script>
311
+ </div>`;
312
+
313
+ it("reports zero mismatches when the SDK output matches the server script", async () => {
314
+ // Produce the "server" script by applying the same op via the SDK, so a
315
+ // faithful SDK writer must reproduce it exactly.
316
+ const ref = await openComposition(BEFORE_HTML);
317
+ const op = {
318
+ kind: "add",
319
+ target: "hf-box",
320
+ tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
321
+ } as const;
322
+ ref.addGsapTween(op.target, op.tween);
323
+ const serverScript =
324
+ ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? "";
325
+
326
+ await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
327
+ expect(lastShadow()).toMatchObject({ op: "gsap_fidelity", dispatched: true, mismatchCount: 0 });
328
+ });
329
+
330
+ it("reports mismatches when the server script diverges", async () => {
331
+ const op = {
332
+ kind: "add",
333
+ target: "hf-box",
334
+ tween: { method: "to", properties: { x: 100 }, duration: 0.5 },
335
+ } as const;
336
+ const ref = await openComposition(BEFORE_HTML);
337
+ ref.addGsapTween(op.target, op.tween);
338
+ const serverScript = (
339
+ ref.serialize().match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/i)?.[1] ?? ""
340
+ ).replace("100", "999");
341
+
342
+ await runShadowGsapFidelity(BEFORE_HTML, op, serverScript);
343
+ const ev = lastShadow();
344
+ expect(ev).toMatchObject({ op: "gsap_fidelity", dispatched: true });
345
+ expect(ev?.mismatchCount as number).toBeGreaterThan(0);
346
+ });
347
+ });
348
+
349
+ describe("resolveGsapFidelityArgs (chokepoint wiring)", () => {
350
+ const op: ShadowGsapOp = { kind: "remove", animationId: "a-1" };
351
+ const session = {} as object;
352
+
353
+ it("returns narrowed args when session, op, before, and serverScript are all present", () => {
354
+ expect(resolveGsapFidelityArgs(session, op, "<html>before</html>", "tl.to(...)")).toEqual({
355
+ before: "<html>before</html>",
356
+ op,
357
+ serverScript: "tl.to(...)",
358
+ });
359
+ });
360
+
361
+ it("returns null when no session (shadow not wired)", () => {
362
+ expect(resolveGsapFidelityArgs(null, op, "before", "script")).toBeNull();
363
+ });
364
+
365
+ it("returns null when no shadowGsapOp (non-meta edit, e.g. property/keyframe)", () => {
366
+ expect(resolveGsapFidelityArgs(session, undefined, "before", "script")).toBeNull();
367
+ });
368
+
369
+ it("returns null when serverScript is null (composition has no GSAP script)", () => {
370
+ expect(resolveGsapFidelityArgs(session, op, "before", null)).toBeNull();
371
+ });
372
+
373
+ it("returns null when before is null", () => {
374
+ expect(resolveGsapFidelityArgs(session, op, null, "script")).toBeNull();
375
+ });
376
+ });