@hyperframes/studio 0.6.106 → 0.6.107

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.
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-koqvg-_0.js";/*!
1
+ import{n as Qi}from"./index-CVHV-S-B.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
@@ -1 +1 @@
1
- import{g as P}from"./index-koqvg-_0.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
1
+ import{g as P}from"./index-CVHV-S-B.js";function j(c,d){for(var s=0;s<d.length;s++){const a=d[s];if(typeof a!="string"&&!Array.isArray(a)){for(const i in a)if(i!=="default"&&!(i in c)){const l=Object.getOwnPropertyDescriptor(a,i);l&&Object.defineProperty(c,i,l.get?l:{enumerable:!0,get:()=>a[i]})}}}return Object.freeze(Object.defineProperty(c,Symbol.toStringTag,{value:"Module"}))}var v={},w;function k(){if(w)return v;w=1,Object.defineProperty(v,"__esModule",{value:!0}),v.default=d;var c=window.OfflineAudioContext||window.webkitOfflineAudioContext;function d(e){var r=a(e);return r.start(0),[i,y,O(e.sampleRate),s].reduce(function(t,o){return o(t)},r.buffer.getChannelData(0))}function s(e){return e.sort(function(r,t){return t.count-r.count}).splice(0,5)[0].tempo}function a(e){var r=e.length,t=e.numberOfChannels,o=e.sampleRate,n=new c(t,r,o),u=n.createBufferSource();u.buffer=e;var f=n.createBiquadFilter();return f.type="lowpass",u.connect(f),f.connect(n.destination),u}function i(e){for(var r=[],t=.9,o=.3,n=15;r.length<n&&t>=o;)r=l(e,t),t-=.05;if(r.length<n)throw new Error("Could not find enough samples for a reliable detection.");return r}function l(e,r){for(var t=[],o=0,n=e.length;o<n;o+=1)e[o]>r&&(t.push(o),o+=1e4);return t}function y(e){var r=[];return e.forEach(function(t,o){for(var n=function(x){var g=e[o+x]-t,_=r.some(function(h){if(h.interval===g)return h.count+=1});_||r.push({interval:g,count:1})},u=0;u<10;u+=1)n(u)}),r}function O(e){return function(r){var t=[];return r.forEach(function(o){if(o.interval!==0){for(var n=60/(o.interval/e);n<90;)n*=2;for(;n>180;)n/=2;n=Math.round(n);var u=t.some(function(f){if(f.tempo===n)return f.count+=o.count});u||t.push({tempo:n,count:o.count})}}),t}}return v}var p,b;function q(){return b||(b=1,p=k().default),p}var m=q();const A=P(m),D=j({__proto__:null,default:A},[m]);export{D as i};
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-koqvg-_0.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-CVHV-S-B.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BrhJl2JY.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.106",
3
+ "version": "0.6.107",
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.106",
37
- "@hyperframes/sdk": "0.6.106",
38
- "@hyperframes/player": "0.6.106"
36
+ "@hyperframes/core": "0.6.107",
37
+ "@hyperframes/player": "0.6.107",
38
+ "@hyperframes/sdk": "0.6.107"
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.106"
52
+ "@hyperframes/producer": "0.6.107"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "19",
@@ -3,6 +3,7 @@ import type { Composition } from "@hyperframes/sdk";
3
3
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
4
  import type { EditHistoryKind } from "../utils/editHistory";
5
5
  import type { ShadowGsapOp } from "../utils/sdkShadow";
6
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
6
7
 
7
8
  export interface MutationResult {
8
9
  ok: boolean;
@@ -19,8 +20,18 @@ export interface CommitMutationOptions {
19
20
  softReload?: boolean;
20
21
  skipReload?: boolean;
21
22
  beforeReload?: () => void;
23
+ /**
24
+ * Serialize this commit against others sharing the same key. Used to chain
25
+ * per-animationId GSAP meta updates so overlapping read-modify-write POSTs to
26
+ * one file can't interleave — which would pair the shadow fidelity diff with a
27
+ * stale server result and report false ease mismatches. Commits without a key
28
+ * (and under distinct keys) run concurrently as before.
29
+ */
30
+ serializeKey?: string;
22
31
  /** Stage 7 Step 3b: typed SDK equivalent of this mutation for value-fidelity shadow. */
23
32
  shadowGsapOp?: ShadowGsapOp;
33
+ /** Typed SDK equivalent of a keyframe mutation for keyframe value-fidelity shadow (gsap_keyframe). */
34
+ shadowKeyframeOp?: ShadowKeyframeOp;
24
35
  }
25
36
 
26
37
  export type CommitMutation = (
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createKeyedSerializer } from "./serializeByKey";
3
+
4
+ function deferred<T>() {
5
+ let resolve!: (value: T) => void;
6
+ let reject!: (reason?: unknown) => void;
7
+ const promise = new Promise<T>((res, rej) => {
8
+ resolve = res;
9
+ reject = rej;
10
+ });
11
+ return { promise, resolve, reject };
12
+ }
13
+
14
+ describe("createKeyedSerializer", () => {
15
+ it("runs same-key tasks strictly in order (second awaits the first)", async () => {
16
+ const run = createKeyedSerializer();
17
+ const order: string[] = [];
18
+ const first = deferred<void>();
19
+
20
+ const p1 = run("k", async () => {
21
+ order.push("1-start");
22
+ await first.promise;
23
+ order.push("1-end");
24
+ });
25
+ const p2 = run("k", async () => {
26
+ order.push("2-start");
27
+ });
28
+
29
+ // Second task must not start until the first finishes.
30
+ await Promise.resolve();
31
+ expect(order).toEqual(["1-start"]);
32
+
33
+ first.resolve();
34
+ await Promise.all([p1, p2]);
35
+ expect(order).toEqual(["1-start", "1-end", "2-start"]);
36
+ });
37
+
38
+ it("does not block tasks under different keys", async () => {
39
+ const run = createKeyedSerializer();
40
+ const order: string[] = [];
41
+ const blockA = deferred<void>();
42
+
43
+ const pa = run("a", async () => {
44
+ order.push("a-start");
45
+ await blockA.promise;
46
+ order.push("a-end");
47
+ });
48
+ const pb = run("b", async () => {
49
+ order.push("b-start");
50
+ order.push("b-end");
51
+ });
52
+
53
+ // Different key runs to completion while "a" is still blocked.
54
+ await pb;
55
+ expect(order).toEqual(["a-start", "b-start", "b-end"]);
56
+
57
+ blockA.resolve();
58
+ await pa;
59
+ expect(order).toEqual(["a-start", "b-start", "b-end", "a-end"]);
60
+ });
61
+
62
+ it("does not wedge a key when a prior task rejects", async () => {
63
+ const run = createKeyedSerializer();
64
+ const order: string[] = [];
65
+
66
+ const p1 = run("k", async () => {
67
+ order.push("1");
68
+ throw new Error("boom");
69
+ });
70
+ await expect(p1).rejects.toThrow("boom");
71
+
72
+ const p2 = run("k", async () => {
73
+ order.push("2");
74
+ });
75
+ await p2;
76
+ expect(order).toEqual(["1", "2"]);
77
+ });
78
+
79
+ it("propagates the task's resolved value to its caller", async () => {
80
+ const run = createKeyedSerializer();
81
+ await expect(run("k", async () => 42)).resolves.toBe(42);
82
+ });
83
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Per-key task serializer. Tasks sharing a key run strictly in order: a new
3
+ * task for a key awaits the prior task for that key before starting, so their
4
+ * effects (e.g. overlapping read-modify-write POSTs to one file) can't
5
+ * interleave. Tasks under different keys are independent and never block each
6
+ * other.
7
+ *
8
+ * Used to serialize GSAP meta-update commits per animationId so the shadow
9
+ * fidelity diff always pairs an op with the server result that includes it —
10
+ * without globally serializing unrelated commits.
11
+ */
12
+ export function createKeyedSerializer() {
13
+ const inFlight = new Map<string, Promise<unknown>>();
14
+
15
+ return function run<T>(key: string, task: () => Promise<T>): Promise<T> {
16
+ const prior = inFlight.get(key) ?? Promise.resolve();
17
+ // Chain onto the prior task regardless of how it settled; a rejected prior
18
+ // commit must not wedge the key forever.
19
+ const next = prior.then(task, task);
20
+ inFlight.set(key, next);
21
+ // Once this task settles, drop it from the map if nothing newer replaced it,
22
+ // so completed keys don't leak.
23
+ void next.then(
24
+ () => {
25
+ if (inFlight.get(key) === next) inFlight.delete(key);
26
+ },
27
+ () => {
28
+ if (inFlight.get(key) === next) inFlight.delete(key);
29
+ },
30
+ );
31
+ return next;
32
+ };
33
+ }
@@ -40,10 +40,14 @@ export function useGsapAnimationOps({
40
40
  animationId,
41
41
  properties: { duration: updates.duration, ease: updates.ease, position: updates.position },
42
42
  };
43
+ // coalesceKey groups rapid meta edits into one history entry. Request
44
+ // serialization is now handled per-file at the commitMutation chokepoint
45
+ // (useGsapScriptCommits), so no per-op serializeKey is needed here.
46
+ const metaKey = `gsap:${animationId}:meta`;
43
47
  commitMutationSafely(
44
48
  selection,
45
49
  { type: "update-meta", animationId, updates },
46
- { label: "Edit GSAP animation", coalesceKey: `gsap:${animationId}:meta`, shadowGsapOp },
50
+ { label: "Edit GSAP animation", coalesceKey: metaKey, shadowGsapOp },
47
51
  );
48
52
  if (sdkSession) runShadowGsapTween(sdkSession, shadowGsapOp);
49
53
  },
@@ -1,5 +1,6 @@
1
1
  import { useCallback } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import type { ShadowKeyframeOp } from "../utils/sdkShadowGsapKeyframe";
3
4
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
4
5
  import { executeOptimistic } from "../utils/optimisticUpdate";
5
6
  import type { KeyframeCacheEntry } from "../player/store/playerStore";
@@ -58,6 +59,13 @@ export function useGsapKeyframeOps({
58
59
  percentage,
59
60
  properties: { [property]: value },
60
61
  };
62
+ // Shadow op (gsap_keyframe): SDK equivalent diffed via the commit chokepoint.
63
+ const shadowKeyframeOp: ShadowKeyframeOp = {
64
+ kind: "add",
65
+ animationId,
66
+ percentage,
67
+ properties: { [property]: value },
68
+ };
61
69
  void executeOptimisticKeyframeCacheUpdate({
62
70
  sourceFile,
63
71
  elementId: selection.id,
@@ -71,6 +79,7 @@ export function useGsapKeyframeOps({
71
79
  commitMutation(selection, mutation, {
72
80
  label: `Add keyframe at ${percentage}%`,
73
81
  softReload: true,
82
+ shadowKeyframeOp,
74
83
  }),
75
84
  }).catch((error) => {
76
85
  trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
@@ -86,10 +95,16 @@ export function useGsapKeyframeOps({
86
95
  percentage: number,
87
96
  properties: Record<string, number | string>,
88
97
  ) => {
98
+ const shadowKeyframeOp: ShadowKeyframeOp = {
99
+ kind: "add",
100
+ animationId,
101
+ percentage,
102
+ properties,
103
+ };
89
104
  return commitMutation(
90
105
  selection,
91
106
  { type: "add-keyframe", animationId, percentage, properties },
92
- { label: `Add keyframe at ${percentage}%`, softReload: true },
107
+ { label: `Add keyframe at ${percentage}%`, softReload: true, shadowKeyframeOp },
93
108
  );
94
109
  },
95
110
  [commitMutation],
@@ -99,6 +114,10 @@ export function useGsapKeyframeOps({
99
114
  (selection: DomEditSelection, animationId: string, percentage: number) => {
100
115
  const sourceFile = selection.sourceFile || activeCompPath || "index.html";
101
116
  const mutation = { type: "remove-keyframe", animationId, percentage };
117
+ // Shadow op (gsap_keyframe): SDK has no %-based removeGsapKeyframe on main,
118
+ // so the runner resolves percentage → keyframeIndex against the pre-op
119
+ // script and no-ops on ambiguity (duplicate-percentage keyframes).
120
+ const shadowKeyframeOp: ShadowKeyframeOp = { kind: "remove", animationId, percentage };
102
121
  void executeOptimisticKeyframeCacheUpdate({
103
122
  sourceFile,
104
123
  elementId: selection.id,
@@ -112,6 +131,7 @@ export function useGsapKeyframeOps({
112
131
  commitMutation(selection, mutation, {
113
132
  label: `Remove keyframe at ${percentage}%`,
114
133
  softReload: true,
134
+ shadowKeyframeOp,
115
135
  }),
116
136
  }).catch((error) => {
117
137
  trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
@@ -1,9 +1,11 @@
1
- import { useCallback } from "react";
1
+ import { useCallback, useRef } 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
5
  import { resolveGsapFidelityArgs, runShadowGsapFidelity } from "../utils/sdkShadowGsapFidelity";
6
+ import { runShadowGsapKeyframeFidelity } from "../utils/sdkShadowGsapKeyframe";
6
7
  import { updateKeyframeCacheFromParsed } from "./gsapKeyframeCacheHelpers";
8
+ import { createKeyedSerializer } from "./serializeByKey";
7
9
  import {
8
10
  GsapMutationHttpError,
9
11
  formatGsapMutationRejectionToast,
@@ -45,10 +47,16 @@ async function mutateGsapScript(
45
47
  // oxfmt-ignore
46
48
  // fallow-ignore-next-line complexity
47
49
  export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession }: GsapScriptCommitsParams) {
50
+ // Serializer for per-key commits (options.serializeKey). Keyed by
51
+ // `gsap:${animationId}:meta`, it chains a meta commit onto the prior one for
52
+ // the same animationId so their POSTs can't interleave — which is what made
53
+ // the shadow fidelity diff pair an op with a stale server result and report
54
+ // false ease mismatches. Held in a ref so the chain survives re-renders.
55
+ const serializerRef = useRef(createKeyedSerializer());
48
56
  // Pre-existing complexity (server mutate + history + reload branches); this PR
49
57
  // adds only a guarded shadow-fidelity dispatch.
50
58
  // fallow-ignore-next-line complexity
51
- const commitMutation = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
59
+ const runCommit = useCallback(async (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
52
60
  const pid = projectIdRef.current;
53
61
  if (!pid) return;
54
62
  const unsafeFields = findUnsafeMutationValues(mutation);
@@ -70,9 +78,10 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
70
78
  domEditSaveTimestampRef.current = Date.now();
71
79
  // Shadow value fidelity: diff the SDK's GSAP writer output against the
72
80
  // 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.
81
+ // Meta-level ops carry shadowGsapOp (add / update-meta / delete via
82
+ // useGsapAnimationOps); keyframe ops carry shadowKeyframeOp (add/remove via
83
+ // useGsapKeyframeOps, handled by the gsap_keyframe block below). Per-property
84
+ // handlers (useGsapPropertyDebounce) don't synthesize one yet — deferred follow-up.
76
85
  // scriptText is null when the composition has no GSAP script; nothing to diff.
77
86
  const fidelityArgs = resolveGsapFidelityArgs(
78
87
  sdkSession,
@@ -83,6 +92,12 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
83
92
  if (fidelityArgs) {
84
93
  void runShadowGsapFidelity(fidelityArgs.before, fidelityArgs.op, fidelityArgs.serverScript);
85
94
  }
95
+ // Keyframe value fidelity (gsap_keyframe): same serialize-diff approach, but
96
+ // the SDK has no keyframe reader so there is no live-existence path — the diff
97
+ // is the only signal. Guarded on a live session + both scripts to diff.
98
+ if (sdkSession && options.shadowKeyframeOp && result.before != null && result.scriptText != null) {
99
+ void runShadowGsapKeyframeFidelity(result.before, options.shadowKeyframeOp, result.scriptText);
100
+ }
86
101
  if (result.before != null && result.after != null) {
87
102
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
88
103
  }
@@ -97,6 +112,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
97
112
  }
98
113
  onCacheInvalidate();
99
114
  }, [projectIdRef, activeCompPath, previewIframeRef, editHistory, domEditSaveTimestampRef, reloadPreview, onCacheInvalidate, onFileContentChanged, showToast, sdkSession]);
115
+ // Every GSAP-script commit is a read-modify-write of one file. Overlapping
116
+ // commits to the SAME file (any op type, any animation) interleave server-side
117
+ // and make the shadow fidelity diff pair an op with a stale server result —
118
+ // the false ease/value mismatches this serializer exists to prevent. So
119
+ // serialize per target file by default; an explicit serializeKey overrides.
120
+ const commitMutation = useCallback(
121
+ (selection: DomEditSelection, mutation: Record<string, unknown>, options: CommitMutationOptions) => {
122
+ const file = selection.sourceFile || activeCompPath || "index.html";
123
+ const key = options.serializeKey ?? `gsap-file:${file}`;
124
+ return serializerRef.current(key, () => runCommit(selection, mutation, options));
125
+ },
126
+ [runCommit, activeCompPath],
127
+ );
100
128
  const trackGsapSaveFailure = useGsapSaveFailureTelemetry(activeCompPath);
101
129
  const commitMutationSafely = useSafeGsapCommitMutation(commitMutation, trackGsapSaveFailure, showToast);
102
130
  const propertyOps = useGsapPropertyDebounce(commitMutationSafely);
@@ -1,4 +1,4 @@
1
- import { describe, expect, it, vi, beforeEach } from "vitest";
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
2
  import {
3
3
  patchOpsToSdkEditOps,
4
4
  runShadowDelete,
@@ -10,8 +10,10 @@ import {
10
10
  SdkShadowMismatch,
11
11
  } from "./sdkShadow";
12
12
  import type { ShadowGsapOp } from "./sdkShadow";
13
+ import { makeSelectorResolver } from "./sdkShadowGsapFidelity";
13
14
  import type { PatchOperation } from "./sourcePatcher";
14
15
  import { openComposition } from "@hyperframes/sdk";
16
+ import { Window } from "happy-dom";
15
17
 
16
18
  // Capture sdk_shadow_dispatch telemetry for the non-PatchOperation runners.
17
19
  const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
@@ -108,6 +110,7 @@ describe("sdkShadowDispatch (integration)", () => {
108
110
  expect(session.getElement("hf-box")?.inlineStyles.color).toBe("#00f");
109
111
  });
110
112
 
113
+ // fallow-ignore-next-line code-duplication
111
114
  it("does NOT false-mismatch a hyphenated style property (kebab op vs camelCase snapshot)", async () => {
112
115
  const { sdkShadowDispatch } = await import("./sdkShadow");
113
116
  const session = await openComposition(BASE_HTML);
@@ -147,6 +150,59 @@ describe("sdkShadowDispatch (integration)", () => {
147
150
  expect(session.getElement("hf-box")?.text).toBe("Updated");
148
151
  });
149
152
 
153
+ // Fix 2: text parity normalization. snapshot.text is trimmed by the SDK, so a
154
+ // trailing-whitespace-only difference between the op value and the snapshot must
155
+ // not flag.
156
+ it("does NOT false-mismatch trailing-whitespace-only text difference", async () => {
157
+ const { sdkShadowDispatch } = await import("./sdkShadow");
158
+ const session = await openComposition(BASE_HTML);
159
+
160
+ const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "World " }];
161
+ const result = sdkShadowDispatch(session, "hf-box", ops);
162
+
163
+ expect(result.dispatched).toBe(true);
164
+ expect(result.mismatches).toHaveLength(0); // trimmed both sides
165
+ });
166
+
167
+ // Empty-string op value vs an absent (null) snapshot text must collapse to equal
168
+ // — both mean "no text content".
169
+ it("treats empty-string text op and null snapshot text as equal", async () => {
170
+ const { sdkShadowDispatch } = await import("./sdkShadow");
171
+ const EMPTY_HTML = /* html */ `<!DOCTYPE html>
172
+ <html><body><img data-hf-id="hf-img" src="x.png" /></body></html>`;
173
+ const session = await openComposition(EMPTY_HTML);
174
+
175
+ const ops: PatchOperation[] = [{ type: "text-content", property: "text", value: "" }];
176
+ const result = sdkShadowDispatch(session, "hf-img", ops);
177
+
178
+ expect(result.dispatched).toBe(true);
179
+ expect(result.mismatches).toHaveLength(0); // "" vs null → both null
180
+ });
181
+
182
+ // Fix 3 verdict (REAL DIVERGENCE, not a readback artifact): the inline-style
183
+ // read-back already reads only the AUTHORED style attribute (getElementStyles →
184
+ // parseStyleAttr), never computed styles. The transform-origin divergence
185
+ // (expected null actual "center center") was a genuine SDK bug — setStyle
186
+ // removal of a HYPHENATED property silently no-opped because setElementStyles
187
+ // deleted the kebab key while the style map is keyed camelCase. Now FIXED in
188
+ // the SDK (model.ts setElementStyles normalizes the key via toCamel), so the
189
+ // shadow sees parity: removal applies and there is no mismatch.
190
+ it("reports clean removal of a hyphenated style (SDK setStyle kebab/camel fix)", async () => {
191
+ const { sdkShadowDispatch } = await import("./sdkShadow");
192
+ const TO_HTML = /* html */ `<!DOCTYPE html>
193
+ <html><body><div data-hf-id="hf-box" style="transform-origin: center center">x</div></body></html>`;
194
+ const session = await openComposition(TO_HTML);
195
+
196
+ const ops: PatchOperation[] = [
197
+ { type: "inline-style", property: "transform-origin", value: null },
198
+ ];
199
+ const result = sdkShadowDispatch(session, "hf-box", ops);
200
+
201
+ // The SDK now removes the hyphenated property, so the shadow read-back agrees.
202
+ expect(result.dispatched).toBe(true);
203
+ expect(result.mismatches).toHaveLength(0);
204
+ });
205
+
150
206
  it("applies attribute op and reads back via session.getElement", async () => {
151
207
  const { sdkShadowDispatch } = await import("./sdkShadow");
152
208
  const session = await openComposition(BASE_HTML);
@@ -237,6 +293,35 @@ describe("runShadowDelete", () => {
237
293
  reason: "cannot_dispatch",
238
294
  });
239
295
  });
296
+
297
+ // Fix 4 verdict (REAL SDK id-resolution divergence, NOT a readback bug): when a
298
+ // bare hf-id collides between a sub-composition element (scopedId
299
+ // "hf-host/hf-dup") and a top-level sibling (scopedId "hf-dup"), removeElement
300
+ // resolves the bare id via resolveScoped → querySelector (document-order-first,
301
+ // removes the INNER instance), but getElement prefers the canonical top-level
302
+ // match (scopedId === id) which SURVIVES. The shadow then correctly reports
303
+ // expected "removed" / actual "present". The readback here is correct (it checks
304
+ // the same id it dispatched); the fix belongs in the SDK's id resolution
305
+ // (resolveScoped vs getElement agreement), not in this file.
306
+ const DUP_ID_HTML = /* html */ `<!DOCTYPE html><html><body>
307
+ <div data-hf-id="hf-root" data-hf-root>
308
+ <div data-hf-id="hf-host" data-composition-file="sub.html">
309
+ <div data-hf-id="hf-dup">inner</div>
310
+ </div>
311
+ <div data-hf-id="hf-dup">outer</div>
312
+ </div>
313
+ </body></html>`;
314
+
315
+ it("reports clean delete for a duplicate bare id (SDK resolves removeElement/getElement to the same instance)", async () => {
316
+ const session = await openComposition(DUP_ID_HTML);
317
+ runShadowDelete(session, "hf-dup");
318
+ // SDK fix (agree removeElement/getElement on duplicate bare ids): both now
319
+ // resolve a bare id to the canonical (top-level) instance, so removeElement
320
+ // drops exactly the element the readback checks → no mismatch. (Previously
321
+ // removeElement dropped the inner instance while the top-level survived,
322
+ // which this shadow correctly flagged; that divergence is now fixed.)
323
+ expect(lastShadow()).toMatchObject({ op: "delete", dispatched: true, mismatchCount: 0 });
324
+ });
240
325
  });
241
326
 
242
327
  describe("runShadowTiming", () => {
@@ -249,6 +334,38 @@ describe("runShadowTiming", () => {
249
334
  expect(el?.trackIndex).toBe(1);
250
335
  expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
251
336
  });
337
+
338
+ // Fix 1: float-precision tolerance. The SDK computes durations arithmetically
339
+ // (returning e.g. 3.0999999999999996); the server stores the rounded literal
340
+ // (3.1). A relative epsilon must treat these as equal, while a real difference
341
+ // still flags. A fake session returns the imprecise value on read-back.
342
+ type FakeTiming = { start?: number; duration?: number; trackIndex?: number };
343
+ function fakeTimingSession(readback: FakeTiming) {
344
+ return {
345
+ can: () => ({ ok: true }),
346
+ batch: (fn: () => void) => fn(),
347
+ dispatch: () => {},
348
+ getElement: () => readback,
349
+ } as unknown as Parameters<typeof runShadowTiming>[0];
350
+ }
351
+
352
+ it("does NOT flag float-precision duration drift (3.1 vs 3.0999999999999996)", () => {
353
+ const session = fakeTimingSession({ duration: 3.0999999999999996 });
354
+ runShadowTiming(session, "hf-clip", { duration: 3.1 });
355
+ expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
356
+ });
357
+
358
+ it("does NOT flag float-precision start drift (21.36 vs 21.360000000000014)", () => {
359
+ const session = fakeTimingSession({ start: 21.360000000000014 });
360
+ runShadowTiming(session, "hf-clip", { start: 21.36 });
361
+ expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 0 });
362
+ });
363
+
364
+ it("STILL flags a real duration difference (3.1 vs 3.5)", () => {
365
+ const session = fakeTimingSession({ duration: 3.5 });
366
+ runShadowTiming(session, "hf-clip", { duration: 3.1 });
367
+ expect(lastShadow()).toMatchObject({ op: "timing", dispatched: true, mismatchCount: 1 });
368
+ });
252
369
  });
253
370
 
254
371
  describe("runShadowGsapTween", () => {
@@ -304,6 +421,29 @@ describe("gsapFidelityMismatches", () => {
304
421
  expect(mismatches.some((m) => m.property === "duration")).toBe(true);
305
422
  });
306
423
 
424
+ it("does NOT flag sub-ULP float-formatting noise in duration", () => {
425
+ // 3.1 vs 3.0999999999999996 is the same value after writer round-trips;
426
+ // relative-epsilon compare must treat it as equal, not drift.
427
+ const sdk = `var tl = gsap.timeline({ paused: true });
428
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.1 }, 0);
429
+ window.__timelines["t"] = tl;`;
430
+ const server = `var tl = gsap.timeline({ paused: true });
431
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 3.0999999999999996 }, 0);
432
+ window.__timelines["t"] = tl;`;
433
+ expect(gsapFidelityMismatches(sdk, server)).toEqual([]);
434
+ });
435
+
436
+ it("STILL flags a real integer duration drift (2 vs 1) past the epsilon", () => {
437
+ const sdk = `var tl = gsap.timeline({ paused: true });
438
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 1 }, 0);
439
+ window.__timelines["t"] = tl;`;
440
+ const server = `var tl = gsap.timeline({ paused: true });
441
+ tl.to("[data-hf-id=\\"hf-box\\"]", { opacity: 1, duration: 2 }, 0);
442
+ window.__timelines["t"] = tl;`;
443
+ const mismatches = gsapFidelityMismatches(sdk, server);
444
+ expect(mismatches.some((m) => m.property === "duration")).toBe(true);
445
+ });
446
+
307
447
  it("flags a tween present in one script but not the other", () => {
308
448
  const empty = `var tl = gsap.timeline({ paused: true });
309
449
  window.__timelines["t"] = tl;`;
@@ -345,6 +485,52 @@ window.__timelines["t"] = tl;`;
345
485
  // With a resolver: matched by element → no mismatch.
346
486
  expect(gsapFidelityMismatches(sdk, server, resolve)).toEqual([]);
347
487
  });
488
+
489
+ // Drive makeSelectorResolver against a real DOM (happy-dom shims the
490
+ // browser-only DOMParser the resolver depends on; the studio test env is node).
491
+ describe("makeSelectorResolver unifies selector forms (real DOM)", () => {
492
+ const origDomParser = (globalThis as { DOMParser?: unknown }).DOMParser;
493
+ beforeEach(() => {
494
+ (globalThis as { DOMParser?: unknown }).DOMParser = new Window().DOMParser;
495
+ });
496
+ afterEach(() => {
497
+ (globalThis as { DOMParser?: unknown }).DOMParser = origDomParser;
498
+ });
499
+
500
+ it("collapses #id / .class / [data-hf-id] for the SAME element to one key", () => {
501
+ // Element carries all three forms; the server may emit #id or .class while
502
+ // the SDK emits [data-hf-id]. All must resolve to the same canonical key.
503
+ const html = `<div data-hf-id="hf-9flp" class="caption-layer" id="intro-layer"></div>`;
504
+ const resolve = makeSelectorResolver(html);
505
+ const viaHfId = resolve('[data-hf-id="hf-9flp"]');
506
+ expect(resolve(".caption-layer")).toBe(viaHfId);
507
+ expect(resolve("#intro-layer")).toBe(viaHfId);
508
+ });
509
+
510
+ it("unifies SDK [data-hf-id] and server .class tweens in the fidelity diff", () => {
511
+ const html = `<div data-hf-id="hf-9flp" class="caption-layer"></div>`;
512
+ const resolve = makeSelectorResolver(html);
513
+ const sdkScript = `var tl = gsap.timeline({ paused: true });
514
+ tl.from("[data-hf-id=\\"hf-9flp\\"]", { opacity: 0, duration: 1 }, 0);
515
+ window.__timelines["t"] = tl;`;
516
+ const serverScript = `var tl = gsap.timeline({ paused: true });
517
+ tl.from(".caption-layer", { opacity: 0, duration: 1 }, 0);
518
+ window.__timelines["t"] = tl;`;
519
+ // Without unification these flag present/absent; the resolver collapses them.
520
+ expect(gsapFidelityMismatches(sdkScript, serverScript).length).toBeGreaterThan(0);
521
+ expect(gsapFidelityMismatches(sdkScript, serverScript, resolve)).toEqual([]);
522
+ });
523
+
524
+ it("collapses different selector forms for an element WITHOUT a data-hf-id", () => {
525
+ // No hf-id present: the resolver must still key both forms to the same node
526
+ // (not leave .class vs #id as distinct raw-selector keys).
527
+ const html = `<div class="caption-layer" id="intro-layer"></div>`;
528
+ const resolve = makeSelectorResolver(html);
529
+ expect(resolve(".caption-layer")).toBe(resolve("#intro-layer"));
530
+ // And it is NOT the raw selector fallback.
531
+ expect(resolve(".caption-layer")).not.toBe(".caption-layer");
532
+ });
533
+ });
348
534
  });
349
535
 
350
536
  describe("runShadowGsapFidelity", () => {