@hyperframes/studio 0.6.106 → 0.6.108

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.
Files changed (49) hide show
  1. package/dist/assets/{hyperframes-player-DgsMQSvV.js → hyperframes-player-67pq7USK.js} +2 -2
  2. package/dist/assets/index-BVqybwMG.css +1 -0
  3. package/dist/assets/index-BgbVhDYd.js +296 -0
  4. package/dist/assets/{index-CBS_7p5W.js → index-Btg-jqxS.js} +1 -1
  5. package/dist/assets/{index-CRCE5Dz_.js → index-tn9M43-U.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/package.json +5 -5
  8. package/src/App.tsx +1 -0
  9. package/src/components/StudioRightPanel.tsx +161 -75
  10. package/src/components/editor/PropertyPanel.tsx +26 -1
  11. package/src/components/editor/domEditOverlayGeometry.ts +2 -11
  12. package/src/components/editor/domEditingDom.ts +21 -0
  13. package/src/components/editor/domEditingElement.ts +2 -11
  14. package/src/components/editor/manualEditingAvailability.test.ts +12 -0
  15. package/src/components/editor/manualEditingAvailability.ts +6 -0
  16. package/src/components/editor/propertyPanelColorGradingControls.tsx +475 -0
  17. package/src/components/editor/propertyPanelColorGradingSection.tsx +355 -0
  18. package/src/components/editor/propertyPanelHelpers.ts +2 -1
  19. package/src/components/editor/propertyPanelPrimitives.tsx +32 -37
  20. package/src/contexts/DomEditContext.tsx +4 -0
  21. package/src/contexts/PanelLayoutContext.tsx +6 -0
  22. package/src/hooks/gsapScriptCommitTypes.ts +11 -0
  23. package/src/hooks/serializeByKey.test.ts +83 -0
  24. package/src/hooks/serializeByKey.ts +33 -0
  25. package/src/hooks/useDomEditCommits.ts +7 -0
  26. package/src/hooks/useDomEditSession.ts +2 -2
  27. package/src/hooks/useDomEditTextCommits.ts +82 -26
  28. package/src/hooks/useDomEditWiring.ts +1 -0
  29. package/src/hooks/useDomSelection.ts +2 -10
  30. package/src/hooks/useGsapAnimationOps.ts +5 -1
  31. package/src/hooks/useGsapKeyframeOps.ts +21 -1
  32. package/src/hooks/useGsapScriptCommits.ts +33 -5
  33. package/src/hooks/usePanelLayout.ts +26 -1
  34. package/src/hooks/useStudioContextValue.ts +8 -3
  35. package/src/icons/SystemIcons.tsx +4 -0
  36. package/src/player/lib/timelineDOM.test.ts +36 -0
  37. package/src/player/lib/timelineDOM.ts +2 -0
  38. package/src/player/lib/timelineElementHelpers.ts +18 -1
  39. package/src/styles/studio.css +81 -0
  40. package/src/utils/mediaTypes.ts +1 -0
  41. package/src/utils/sdkShadow.test.ts +187 -1
  42. package/src/utils/sdkShadow.ts +67 -10
  43. package/src/utils/sdkShadowGsapFidelity.ts +46 -18
  44. package/src/utils/sdkShadowGsapKeyframe.test.ts +265 -0
  45. package/src/utils/sdkShadowGsapKeyframe.ts +257 -0
  46. package/src/utils/sdkShadowNumeric.ts +11 -0
  47. package/src/utils/studioHelpers.ts +6 -0
  48. package/dist/assets/index-BrhJl2JY.css +0 -1
  49. package/dist/assets/index-koqvg-_0.js +0 -296
@@ -20,6 +20,87 @@ body {
20
20
  overflow: hidden;
21
21
  }
22
22
 
23
+ .hf-color-grading-number::-webkit-outer-spin-button,
24
+ .hf-color-grading-number::-webkit-inner-spin-button {
25
+ margin: 0;
26
+ appearance: none;
27
+ }
28
+
29
+ .hf-color-grading-number {
30
+ -moz-appearance: textfield;
31
+ appearance: textfield;
32
+ }
33
+
34
+ .hf-color-grading-range {
35
+ height: 1.5rem;
36
+ cursor: default;
37
+ -webkit-appearance: none;
38
+ appearance: none;
39
+ background: transparent;
40
+ }
41
+
42
+ .hf-color-grading-range:disabled {
43
+ cursor: not-allowed;
44
+ opacity: 0.5;
45
+ }
46
+
47
+ .hf-color-grading-range::-webkit-slider-runnable-track {
48
+ height: 1.25rem;
49
+ border: 0;
50
+ background: transparent;
51
+ }
52
+
53
+ .hf-color-grading-range::-webkit-slider-thumb {
54
+ width: 0.625rem;
55
+ height: 1rem;
56
+ margin-top: 0.125rem;
57
+ border: 0;
58
+ border-radius: 999px;
59
+ -webkit-appearance: none;
60
+ appearance: none;
61
+ background: #ffffff;
62
+ box-shadow:
63
+ 0 0 0 2px #0c0c0e,
64
+ 0 1px 4px rgba(0, 0, 0, 0.55);
65
+ cursor: default;
66
+ }
67
+
68
+ .hf-color-grading-range::-moz-range-track {
69
+ height: 1.25rem;
70
+ border: 0;
71
+ background: transparent;
72
+ }
73
+
74
+ .hf-color-grading-range::-moz-range-thumb {
75
+ width: 0.625rem;
76
+ height: 1rem;
77
+ border: 0;
78
+ border-radius: 999px;
79
+ background: #ffffff;
80
+ box-shadow:
81
+ 0 0 0 2px #0c0c0e,
82
+ 0 1px 4px rgba(0, 0, 0, 0.55);
83
+ cursor: default;
84
+ }
85
+
86
+ .hf-color-grading-range:focus-visible {
87
+ outline: none;
88
+ }
89
+
90
+ .hf-color-grading-range:focus-visible::-webkit-slider-thumb {
91
+ box-shadow:
92
+ 0 0 0 2px #0c0c0e,
93
+ 0 0 0 4px rgba(60, 230, 172, 0.22),
94
+ 0 1px 4px rgba(0, 0, 0, 0.55);
95
+ }
96
+
97
+ .hf-color-grading-range:focus-visible::-moz-range-thumb {
98
+ box-shadow:
99
+ 0 0 0 2px #0c0c0e,
100
+ 0 0 0 4px rgba(60, 230, 172, 0.22),
101
+ 0 1px 4px rgba(0, 0, 0, 0.55);
102
+ }
103
+
23
104
  #root {
24
105
  width: 100vw;
25
106
  /*
@@ -2,6 +2,7 @@ export const IMAGE_EXT = /\.(jpg|jpeg|png|gif|webp|svg|ico)$/i;
2
2
  export const VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3
3
  export const AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
4
4
  export const FONT_EXT = /\.(woff|woff2|ttf|ttc|otf|eot)$/i;
5
+ export const LUT_EXT = /\.cube$/i;
5
6
  export const MEDIA_EXT = /\.(mp4|webm|mov|mp3|wav|ogg|m4a|aac|jpg|jpeg|png|gif|webp|svg|ico)$/i;
6
7
 
7
8
  export function isMediaFile(path: string): boolean {
@@ -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", () => {
@@ -12,6 +12,7 @@ import type { Composition } from "@hyperframes/sdk";
12
12
  import type { EditOp, GsapTweenSpec } from "@hyperframes/sdk";
13
13
  import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
14
14
  import { trackStudioEvent } from "./studioTelemetry";
15
+ import { relEqual } from "./sdkShadowNumeric";
15
16
  import type { DomEditSelection } from "../components/editor/domEditingTypes";
16
17
  import type { PatchOperation } from "./sourcePatcher";
17
18
 
@@ -39,6 +40,16 @@ function isShadowableOp(op: PatchOperation): boolean {
39
40
  return true;
40
41
  }
41
42
 
43
+ // PatchOperation types patchOpsToSdkEditOps knows how to map. Used by
44
+ // runShadowDispatch to flag any unmapped type as visible telemetry rather than
45
+ // silently dropping it (see the unmapped_type guard there).
46
+ const MAPPED_PATCH_OP_TYPES: ReadonlySet<string> = new Set([
47
+ "inline-style",
48
+ "text-content",
49
+ "attribute",
50
+ "html-attribute",
51
+ ]);
52
+
42
53
  export function patchOpsToSdkEditOps(hfId: string, ops: PatchOperation[]): EditOp[] {
43
54
  const result: EditOp[] = [];
44
55
  const styles: Record<string, string | null> = {};
@@ -121,13 +132,29 @@ function kebabToCamel(prop: string): string {
121
132
  return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
122
133
  }
123
134
 
135
+ // Text parity: the SDK snapshot.text is trimmed, so trim the op value too.
136
+ // An empty string and absent text (null) are treated as equivalent (collapsed
137
+ // to null) so "" vs null does not flag — both mean "no text content".
138
+ function normalizeText(value: string | null | undefined): string | null {
139
+ if (value == null) return null;
140
+ const trimmed = value.trim();
141
+ return trimmed === "" ? null : trimmed;
142
+ }
143
+
124
144
  const OP_FIELD_RESOLVERS: Record<string, OpFieldResolver> = {
125
145
  "inline-style": (op, flat) => ({
126
146
  property: op.property,
127
147
  expected: op.value,
128
148
  actual: flat.styles[kebabToCamel(op.property)] ?? flat.styles[op.property] ?? null,
129
149
  }),
130
- "text-content": (op, flat) => ({ property: "text", expected: op.value ?? "", actual: flat.text }),
150
+ // snapshot.text is already TRIMMED; trim the expected op value to match, so
151
+ // trailing-whitespace differences don't flag. Empty-vs-absent ("" vs null) is
152
+ // collapsed in checkOpParity. A genuinely different text value still flags.
153
+ "text-content": (op, flat) => ({
154
+ property: "text",
155
+ expected: normalizeText(op.value),
156
+ actual: normalizeText(flat.text),
157
+ }),
131
158
  attribute: (op, flat) => ({
132
159
  property: attrName(op.property),
133
160
  expected: op.value ?? null,
@@ -244,6 +271,23 @@ export function runShadowDispatch(
244
271
  });
245
272
  return;
246
273
  }
274
+ // Defensive: patchOpsToSdkEditOps silently drops PatchOperation types it
275
+ // doesn't map. PatchOperation.type is a closed union today, but emit a visible
276
+ // unmapped_type event if a future type ever slips through, so the gap surfaces
277
+ // in telemetry instead of vanishing.
278
+ // Map to the type string before find, so a future unmapped type is read as a
279
+ // plain string (no object cast; find on the closed union narrows to never).
280
+ const unmappedType = ops.map((op) => op.type).find((t) => !MAPPED_PATCH_OP_TYPES.has(t));
281
+ if (unmappedType !== undefined) {
282
+ trackStudioEvent("sdk_shadow_dispatch", {
283
+ op: "property",
284
+ dispatched: false,
285
+ reason: "unmapped_type",
286
+ type: unmappedType,
287
+ mismatchCount: 0,
288
+ });
289
+ return;
290
+ }
247
291
  const result = sdkShadowDispatch(session, hfId, ops);
248
292
  trackStudioEvent("sdk_shadow_dispatch", {
249
293
  op: "property",
@@ -345,6 +389,20 @@ export interface ShadowTiming {
345
389
  trackIndex?: number;
346
390
  }
347
391
 
392
+ // start/duration tolerate float-precision drift (SDK computes them
393
+ // arithmetically, server stores a rounded literal) via the shared relative
394
+ // epsilon; trackIndex (integer track slot) is compared exactly.
395
+ function timingFieldEqual(
396
+ key: keyof ShadowTiming,
397
+ actual: number | null | undefined,
398
+ expected: number,
399
+ ): boolean {
400
+ if (typeof actual === "number" && key !== "trackIndex") {
401
+ return relEqual(actual, expected);
402
+ }
403
+ return actual === expected;
404
+ }
405
+
348
406
  /** Shadow a timing edit. Parity: snapshot start/duration/trackIndex match. */
349
407
  export function runShadowTiming(
350
408
  session: Composition,
@@ -373,15 +431,14 @@ export function runShadowTiming(
373
431
  ];
374
432
  for (const [key, actual] of fields) {
375
433
  const expected = timing[key];
376
- if (expected !== undefined && actual !== expected) {
377
- mismatches.push({
378
- kind: "value_mismatch",
379
- hfId,
380
- property: key,
381
- expected: String(expected),
382
- actual: actual == null ? null : String(actual),
383
- });
384
- }
434
+ if (expected === undefined || timingFieldEqual(key, actual, expected)) continue;
435
+ mismatches.push({
436
+ kind: "value_mismatch",
437
+ hfId,
438
+ property: key,
439
+ expected: String(expected),
440
+ actual: actual == null ? null : String(actual),
441
+ });
385
442
  }
386
443
  return mismatches;
387
444
  });
@@ -16,6 +16,7 @@ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
16
16
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
17
17
  import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
18
18
  import { trackStudioEvent } from "./studioTelemetry";
19
+ import { relEqual } from "./sdkShadowNumeric";
19
20
  import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
20
21
 
21
22
  // Marker set must match document.ts extractGsapScript so both pick the same
@@ -24,7 +25,7 @@ function isGsapScriptBody(body: string): boolean {
24
25
  return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
25
26
  }
26
27
 
27
- function extractGsapScript(html: string): string | null {
28
+ export function extractGsapScript(html: string): string | null {
28
29
  // Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
29
30
  // before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
30
31
  const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
@@ -73,17 +74,17 @@ function animByKey(
73
74
  // number-vs-string forms. Compare canonically — sort keys, coerce numeric
74
75
  // strings — so only real value drift registers, not formatting differences.
75
76
 
77
+ // Coerce string operands to numbers, then compare with the shared relative
78
+ // epsilon (relEqual) so float-formatting noise (3.1 vs 3.0999999999999996)
79
+ // isn't flagged as drift while a real 2 vs 1 still is.
76
80
  function numericEqual(a: unknown, b: unknown): boolean {
77
81
  if (a === b) return true;
78
82
  const na = typeof a === "string" ? Number(a) : a;
79
83
  const nb = typeof b === "string" ? Number(b) : b;
80
- return (
81
- typeof na === "number" &&
82
- typeof nb === "number" &&
83
- !Number.isNaN(na) &&
84
- !Number.isNaN(nb) &&
85
- na === nb
86
- );
84
+ if (typeof na !== "number" || typeof nb !== "number" || Number.isNaN(na) || Number.isNaN(nb)) {
85
+ return false;
86
+ }
87
+ return relEqual(na, nb);
87
88
  }
88
89
 
89
90
  function canonicalProps(obj: Record<string, unknown> | undefined): string {
@@ -187,27 +188,54 @@ export function resolveGsapFidelityArgs(
187
188
  return { before, op: shadowGsapOp, serverScript };
188
189
  }
189
190
 
190
- // Resolve a CSS selector to a canonical element id (data-hf-id) using the pre-op
191
- // document, so tweens that target the same element via different selectors
192
- // ([data-hf-id="X"] vs .X) match in the fidelity diff. Falls back to the raw
193
- // selector when it can't resolve (DOMParser unavailable, no match, bad selector).
191
+ // Resolve a CSS selector to a canonical element key using the pre-op document,
192
+ // so tweens that target the same element via different selectors
193
+ // ([data-hf-id="X"] vs .X vs #X) collapse to one key in the fidelity diff.
194
+ //
195
+ // The SDK writer emits [data-hf-id="X"] while the server may emit a class/id
196
+ // selector for the SAME element. Keying both forms to the same node prevents a
197
+ // false present/absent mismatch. Resolution order, for whatever element the
198
+ // selector matches:
199
+ // 1. data-hf-id present → "hfid:<id>" (the common, stable case)
200
+ // 2. no data-hf-id → "node:<n>" (per-document node index; identical
201
+ // regardless of which selector form found the node, so .x and [data-hf-id]
202
+ // pointing at the same attribute-less node still collapse)
203
+ // 3. selector resolves to no node / parse error / no DOM → the raw selector
204
+ // (last resort; only diverges when the two writers genuinely target
205
+ // different — or unresolvable — nodes, which is real drift to surface)
206
+ // The "hfid:"/"node:" prefixes are namespaced so a canonical key can never
207
+ // collide with a raw-selector fallback.
194
208
  //
195
209
  // ponytail: first-match heuristic — querySelector returns the FIRST match, so an
196
- // ambiguous selector (e.g. .x shared by two elements) may map to a different id
197
- // than the SDK side's [data-hf-id] target and still flag present/absent. Safe
198
- // for studio templates (one tween per data-hf-id); upgrade to querySelectorAll +
199
- // uniqueness check if ambiguous selectors appear.
200
- function makeSelectorResolver(html: string): (sel: string) => string {
210
+ // ambiguous selector (e.g. .x shared by two elements) may map to a different
211
+ // node than the SDK side's [data-hf-id] target and still flag present/absent.
212
+ // Safe for studio templates (one tween per element); upgrade to querySelectorAll
213
+ // + uniqueness check if ambiguous selectors appear.
214
+ export function makeSelectorResolver(html: string): (sel: string) => string {
201
215
  let doc: Document | null = null;
202
216
  try {
203
217
  doc = new DOMParser().parseFromString(html, "text/html");
204
218
  } catch {
205
219
  doc = null;
206
220
  }
221
+ // Stable per-node index so an attribute-less element keys identically no
222
+ // matter which selector form (class vs id vs [data-hf-id]) resolved it.
223
+ const nodeKeys = new WeakMap<Element, string>();
224
+ let nextNode = 0;
225
+ const keyForNode = (el: Element): string => {
226
+ const hfId = el.getAttribute("data-hf-id");
227
+ if (hfId != null && hfId !== "") return `hfid:${hfId}`;
228
+ const existing = nodeKeys.get(el);
229
+ if (existing != null) return existing;
230
+ const key = `node:${nextNode++}`;
231
+ nodeKeys.set(el, key);
232
+ return key;
233
+ };
207
234
  return (sel) => {
208
235
  if (!doc) return sel;
209
236
  try {
210
- return doc.querySelector(sel)?.getAttribute("data-hf-id") ?? sel;
237
+ const el = doc.querySelector(sel);
238
+ return el ? keyForNode(el) : sel;
211
239
  } catch {
212
240
  return sel;
213
241
  }