@hyperframes/studio 0.6.101 → 0.6.102

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.
@@ -367,12 +367,15 @@ export type ShadowGsapOp =
367
367
  | { kind: "remove"; animationId: string };
368
368
 
369
369
  /**
370
- * Shadow a GSAP tween mutation. Snapshot value-parity is NOT available: the
371
- * tween lives in the GSAP <script>, and ElementSnapshot.animationIds is a stub
372
- * (always [] — see sdk document.ts). So the signal here is can() addressing /
373
- * validity + dispatch-didn't-throw, plus (for add) that the SDK returned a
374
- * non-empty tween id. Full fidelity needs serialize()-script round-trip diffing,
375
- * out of scope for shadow. // ponytail: upgrade when animationIds is populated.
370
+ * Shadow a GSAP tween mutation (add / set / remove). The server's animationId
371
+ * shares the SDK's id-space (both derive `targetSelector-method-position` from
372
+ * the same acorn parser — see sdk assignStableIds), so it is dispatchable as-is.
373
+ *
374
+ * Parity via the now-populated ElementSnapshot.animationIds:
375
+ * add → the returned tween id is present on the target element
376
+ * remove → the id is gone from every element
377
+ * set → existence only (the SDK exposes no per-tween property reader; value
378
+ * fidelity would need serialize()-script round-trip diffing).
376
379
  */
377
380
  export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp): void {
378
381
  if (!STUDIO_SDK_SHADOW_ENABLED) return;
@@ -382,23 +385,50 @@ export function runShadowGsapTween(session: Composition, gsapOp: ShadowGsapOp):
382
385
  : gsapOp.kind === "set"
383
386
  ? { type: "setGsapTween", animationId: gsapOp.animationId, properties: gsapOp.properties }
384
387
  : { type: "removeGsapTween", animationId: gsapOp.animationId };
388
+ // fallow-ignore-next-line complexity
385
389
  runShadowEditOp(session, op, "gsap", () => {
386
390
  let newId: string | undefined;
387
391
  session.batch(() => {
388
392
  if (gsapOp.kind === "add") newId = session.addGsapTween(gsapOp.target, gsapOp.tween);
389
393
  else session.dispatch(op);
390
394
  });
391
- if (gsapOp.kind === "add" && !newId) {
392
- return [
393
- {
394
- kind: "value_mismatch",
395
- hfId: gsapOp.target,
396
- property: "tweenId",
397
- expected: "non-empty",
398
- actual: null,
399
- },
400
- ];
395
+ if (gsapOp.kind === "add") {
396
+ const onTarget = session.getElement(gsapOp.target)?.animationIds ?? [];
397
+ if (!newId || !onTarget.includes(newId)) {
398
+ return [
399
+ {
400
+ kind: "value_mismatch",
401
+ hfId: gsapOp.target,
402
+ property: "animationIds",
403
+ expected: newId ?? "non-empty",
404
+ actual: onTarget.join(",") || null,
405
+ },
406
+ ];
407
+ }
408
+ } else if (gsapOp.kind === "remove") {
409
+ const stillPresent = session
410
+ .getElements()
411
+ .some((el) => el.animationIds.includes(gsapOp.animationId));
412
+ if (stillPresent) {
413
+ return [
414
+ {
415
+ kind: "value_mismatch",
416
+ hfId: gsapOp.animationId,
417
+ property: "animationIds",
418
+ expected: "removed",
419
+ actual: "present",
420
+ },
421
+ ];
422
+ }
401
423
  }
402
424
  return [];
403
425
  });
404
426
  }
427
+
428
+ // GSAP value-fidelity diff lives in its own module to keep this file under the
429
+ // 600-line studio cap; re-exported here so the shadow surface stays in one place.
430
+ export {
431
+ gsapFidelityMismatches,
432
+ resolveGsapFidelityArgs,
433
+ runShadowGsapFidelity,
434
+ } from "./sdkShadowGsapFidelity";
@@ -0,0 +1,208 @@
1
+ /**
2
+ * GSAP value-fidelity shadow (serialize round-trip diff). Split out of
3
+ * sdkShadow.ts to keep that file under the 600-line studio cap.
4
+ *
5
+ * Existence parity (sdkShadow.ts) confirms a tween was created/removed, but not
6
+ * that its VALUES (duration / ease / position / properties) match the server.
7
+ * The SDK exposes no per-tween property reader, so we compare the two writers'
8
+ * output: apply the same op to a fresh SDK doc opened from the server's pre-op
9
+ * file, then structurally diff the SDK's GSAP script against the server's
10
+ * resulting script. Both are re-parsed, so formatting/whitespace differences
11
+ * never produce false positives — only real value drift does.
12
+ */
13
+
14
+ import { openComposition } from "@hyperframes/sdk";
15
+ import { parseGsapScriptAcorn } from "@hyperframes/core/gsap-parser-acorn";
16
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
17
+ import { STUDIO_SDK_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
18
+ import { trackStudioEvent } from "./studioTelemetry";
19
+ import type { SdkShadowMismatch, ShadowGsapOp } from "./sdkShadow";
20
+
21
+ // Marker set must match document.ts extractGsapScript so both pick the same
22
+ // <script> from any given composition.
23
+ function isGsapScriptBody(body: string): boolean {
24
+ return body.includes("gsap") || body.includes("__timelines") || body.includes("ScrollTrigger");
25
+ }
26
+
27
+ function extractGsapScript(html: string): string | null {
28
+ // Close tag is `</script[^>]*>` (not just `</script>`) — HTML5 ignores junk
29
+ // before the `>`, e.g. `</script >` or `</script foo>` (CodeQL js/bad-tag-filter).
30
+ const scripts = html.match(/<script\b[^>]*>([\s\S]*?)<\/script[^>]*>/gi);
31
+ if (!scripts) return null;
32
+ for (const block of scripts) {
33
+ const body = block.replace(/^<script\b[^>]*>/i, "").replace(/<\/script[^>]*>$/i, "");
34
+ if (isGsapScriptBody(body)) return body;
35
+ }
36
+ return null;
37
+ }
38
+
39
+ function animById(script: string): Map<string, GsapAnimation> {
40
+ const map = new Map<string, GsapAnimation>();
41
+ const parsed = parseGsapScriptAcorn(script);
42
+ for (const anim of parsed.animations) map.set(anim.id, anim);
43
+ return map;
44
+ }
45
+
46
+ // The server (addAnimationToScript) and SDK (gsapWriterAcorn) are DIFFERENT
47
+ // writers, so the same tween can serialize with different property key order or
48
+ // number-vs-string forms. Compare canonically — sort keys, coerce numeric
49
+ // strings — so only real value drift registers, not formatting differences.
50
+
51
+ function numericEqual(a: unknown, b: unknown): boolean {
52
+ if (a === b) return true;
53
+ const na = typeof a === "string" ? Number(a) : a;
54
+ const nb = typeof b === "string" ? Number(b) : b;
55
+ return (
56
+ typeof na === "number" &&
57
+ typeof nb === "number" &&
58
+ !Number.isNaN(na) &&
59
+ !Number.isNaN(nb) &&
60
+ na === nb
61
+ );
62
+ }
63
+
64
+ function canonicalProps(obj: Record<string, unknown> | undefined): string {
65
+ if (!obj) return "{}";
66
+ const out: Record<string, unknown> = {};
67
+ for (const key of Object.keys(obj).sort()) {
68
+ const v = obj[key];
69
+ // normalize "0.5" → 0.5 so a number/string writer difference isn't drift
70
+ out[key] = typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v)) ? Number(v) : v;
71
+ }
72
+ return JSON.stringify(out);
73
+ }
74
+
75
+ /**
76
+ * Structurally diff two GSAP scripts by tween id. Reports a tween present in
77
+ * one but not the other, and per-field value drift (method, position, duration,
78
+ * ease, properties, fromProperties). Comparison is canonical (see above) so
79
+ * writer formatting differences do not produce false mismatches.
80
+ */
81
+ // fallow-ignore-next-line complexity
82
+ export function gsapFidelityMismatches(
83
+ sdkScript: string,
84
+ serverScript: string,
85
+ ): SdkShadowMismatch[] {
86
+ const sdk = animById(sdkScript);
87
+ const server = animById(serverScript);
88
+ const mismatches: SdkShadowMismatch[] = [];
89
+ const ids = new Set([...sdk.keys(), ...server.keys()]);
90
+ for (const id of ids) {
91
+ const a = sdk.get(id);
92
+ const b = server.get(id);
93
+ if (!a || !b) {
94
+ mismatches.push({
95
+ kind: "value_mismatch",
96
+ hfId: id,
97
+ property: "tween",
98
+ expected: b ? "present" : "absent",
99
+ actual: a ? "present" : "absent",
100
+ });
101
+ continue;
102
+ }
103
+ // [property, sdk-value, server-value, equal?]
104
+ const fields: Array<[string, unknown, unknown, boolean]> = [
105
+ ["method", a.method, b.method, a.method === b.method],
106
+ ["position", a.position, b.position, numericEqual(a.position, b.position)],
107
+ ["duration", a.duration, b.duration, numericEqual(a.duration, b.duration)],
108
+ ["ease", a.ease, b.ease, a.ease === b.ease],
109
+ [
110
+ "properties",
111
+ a.properties,
112
+ b.properties,
113
+ canonicalProps(a.properties) === canonicalProps(b.properties),
114
+ ],
115
+ [
116
+ "fromProperties",
117
+ a.fromProperties,
118
+ b.fromProperties,
119
+ canonicalProps(a.fromProperties) === canonicalProps(b.fromProperties),
120
+ ],
121
+ ];
122
+ for (const [property, av, bv, equal] of fields) {
123
+ if (!equal) {
124
+ mismatches.push({
125
+ kind: "value_mismatch",
126
+ hfId: id,
127
+ property,
128
+ expected: bv == null ? null : JSON.stringify(bv),
129
+ actual: av == null ? null : JSON.stringify(av),
130
+ });
131
+ }
132
+ }
133
+ }
134
+ return mismatches;
135
+ }
136
+
137
+ export interface GsapFidelityArgs {
138
+ before: string;
139
+ op: ShadowGsapOp;
140
+ serverScript: string;
141
+ }
142
+
143
+ /**
144
+ * Wiring gate for the commitMutation chokepoint: return the narrowed fidelity
145
+ * args only when there is a live session, a typed shadow op, and both the
146
+ * pre-op file and the server's resulting script to diff against (scriptText is
147
+ * null when the composition has no GSAP script). Returns null otherwise. Pure +
148
+ * narrowing so the wiring decision is unit-testable without rendering the hook
149
+ * and the caller needs no non-null assertions.
150
+ */
151
+ export function resolveGsapFidelityArgs(
152
+ sdkSession: unknown,
153
+ shadowGsapOp: ShadowGsapOp | undefined,
154
+ before: string | null | undefined,
155
+ serverScript: string | null | undefined,
156
+ ): GsapFidelityArgs | null {
157
+ if (!sdkSession || !shadowGsapOp || before == null || serverScript == null) return null;
158
+ return { before, op: shadowGsapOp, serverScript };
159
+ }
160
+
161
+ /**
162
+ * Shadow GSAP value fidelity: open a fresh SDK doc from the server's pre-op
163
+ * file, apply the same tween op, serialize, and diff the SDK's GSAP script
164
+ * against the server's resulting script. Emits sdk_shadow_dispatch op:
165
+ * "gsap_fidelity". Async, fire-and-forget; server stays authoritative.
166
+ */
167
+ export async function runShadowGsapFidelity(
168
+ beforeHtml: string,
169
+ gsapOp: ShadowGsapOp,
170
+ serverScript: string,
171
+ ): Promise<void> {
172
+ if (!STUDIO_SDK_SHADOW_ENABLED) return;
173
+ // No server script to diff against → skip the (costly) openComposition.
174
+ if (!serverScript || !beforeHtml) return;
175
+ try {
176
+ const session = await openComposition(beforeHtml);
177
+ session.batch(() => {
178
+ if (gsapOp.kind === "add") session.addGsapTween(gsapOp.target, gsapOp.tween);
179
+ else if (gsapOp.kind === "set") session.setGsapTween(gsapOp.animationId, gsapOp.properties);
180
+ else session.removeGsapTween(gsapOp.animationId);
181
+ });
182
+ const sdkScript = extractGsapScript(session.serialize());
183
+ if (sdkScript == null) {
184
+ trackStudioEvent("sdk_shadow_dispatch", {
185
+ op: "gsap_fidelity",
186
+ dispatched: false,
187
+ reason: "no_sdk_script",
188
+ mismatchCount: 0,
189
+ });
190
+ return;
191
+ }
192
+ const mismatches = gsapFidelityMismatches(sdkScript, serverScript);
193
+ trackStudioEvent("sdk_shadow_dispatch", {
194
+ op: "gsap_fidelity",
195
+ dispatched: true,
196
+ mismatchCount: mismatches.length,
197
+ mismatches: JSON.stringify(mismatches),
198
+ });
199
+ } catch (err) {
200
+ trackStudioEvent("sdk_shadow_dispatch", {
201
+ op: "gsap_fidelity",
202
+ dispatched: false,
203
+ reason: "fidelity_error",
204
+ error: String(err),
205
+ mismatchCount: 0,
206
+ });
207
+ }
208
+ }