@hyperframes/studio 0.6.110 → 0.6.111

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 (58) hide show
  1. package/dist/assets/index-B7S86vK1.js +370 -0
  2. package/dist/assets/index-DP8pPIk2.css +1 -0
  3. package/dist/assets/{index-CfiyaR7G.js → index-_IV-vm9l.js} +1 -1
  4. package/dist/assets/{index-CXy_mWnP.js → index-x0c2-zQN.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +8 -5
  7. package/src/App.tsx +133 -141
  8. package/src/components/StudioHeader.tsx +44 -0
  9. package/src/components/StudioOverlays.tsx +70 -0
  10. package/src/components/editor/SourceEditor.tsx +19 -16
  11. package/src/components/editor/manualEditingAvailability.ts +25 -7
  12. package/src/components/storyboard/FramePoster.tsx +56 -0
  13. package/src/components/storyboard/StoryboardDirection.tsx +45 -0
  14. package/src/components/storyboard/StoryboardFrameFocus.tsx +262 -0
  15. package/src/components/storyboard/StoryboardFrameTile.tsx +88 -0
  16. package/src/components/storyboard/StoryboardGrid.tsx +33 -0
  17. package/src/components/storyboard/StoryboardLoaded.tsx +136 -0
  18. package/src/components/storyboard/StoryboardScriptPanel.tsx +26 -0
  19. package/src/components/storyboard/StoryboardSourceEditor.tsx +231 -0
  20. package/src/components/storyboard/StoryboardStatusLegend.tsx +21 -0
  21. package/src/components/storyboard/StoryboardView.tsx +78 -0
  22. package/src/components/storyboard/frameStatus.ts +36 -0
  23. package/src/contexts/ViewModeContext.tsx +98 -0
  24. package/src/hooks/gsapScriptCommitTypes.ts +4 -7
  25. package/src/hooks/sdkSelfWriteRegistry.test.ts +67 -0
  26. package/src/hooks/sdkSelfWriteRegistry.ts +77 -0
  27. package/src/hooks/timelineEditingHelpers.ts +2 -0
  28. package/src/hooks/useAppHotkeys.ts +20 -0
  29. package/src/hooks/useDomEditCommits.ts +43 -14
  30. package/src/hooks/useDomEditSession.test.ts +41 -0
  31. package/src/hooks/useDomEditSession.ts +38 -6
  32. package/src/hooks/useDomGeometryCommits.ts +5 -9
  33. package/src/hooks/useElementLifecycleOps.ts +30 -0
  34. package/src/hooks/useGsapAnimationOps.ts +78 -51
  35. package/src/hooks/useGsapKeyframeOps.ts +127 -43
  36. package/src/hooks/useGsapPropertyDebounce.test.ts +70 -0
  37. package/src/hooks/useGsapPropertyDebounce.ts +201 -23
  38. package/src/hooks/useGsapPropertyDebounceFlush.test.ts +85 -0
  39. package/src/hooks/useGsapScriptCommits.ts +95 -40
  40. package/src/hooks/useSdkSession.test.ts +12 -0
  41. package/src/hooks/useSdkSession.ts +91 -25
  42. package/src/hooks/useStoryboard.ts +80 -0
  43. package/src/hooks/useTimelineEditing.ts +163 -68
  44. package/src/utils/gsapSoftReload.ts +14 -0
  45. package/src/utils/sdkCutover.gate.test.ts +61 -0
  46. package/src/utils/sdkCutover.test.ts +839 -0
  47. package/src/utils/sdkCutover.ts +465 -0
  48. package/src/utils/sdkOpMapping.ts +43 -0
  49. package/src/utils/sdkResolverShadow.test.ts +366 -0
  50. package/src/utils/sdkResolverShadow.ts +313 -0
  51. package/dist/assets/index-1C8oFiPi.css +0 -1
  52. package/dist/assets/index-D-3sGz65.js +0 -296
  53. package/src/utils/sdkShadow.test.ts +0 -606
  54. package/src/utils/sdkShadow.ts +0 -517
  55. package/src/utils/sdkShadowGsapFidelity.ts +0 -296
  56. package/src/utils/sdkShadowGsapKeyframe.test.ts +0 -265
  57. package/src/utils/sdkShadowGsapKeyframe.ts +0 -257
  58. package/src/utils/sdkShadowNumeric.ts +0 -11
@@ -0,0 +1,366 @@
1
+ import { describe, expect, it, vi, beforeEach } from "vitest";
2
+ import {
3
+ sdkResolverShadowCheck,
4
+ runResolverShadow,
5
+ recordResolverParity,
6
+ recordAnimationResolverParity,
7
+ evaluateSoakGate,
8
+ type SdkResolverMismatch,
9
+ } from "./sdkResolverShadow";
10
+ import type { PatchOperation } from "./sourcePatcher";
11
+ import { openComposition } from "@hyperframes/sdk";
12
+
13
+ // ─── Telemetry capture ────────────────────────────────────────────────────────
14
+
15
+ const trackedEvents: Array<{ event: string; props: Record<string, unknown> }> = [];
16
+ vi.mock("./studioTelemetry", () => ({
17
+ trackStudioEvent: (event: string, props: Record<string, unknown>) =>
18
+ trackedEvents.push({ event, props }),
19
+ }));
20
+ beforeEach(() => {
21
+ trackedEvents.length = 0;
22
+ });
23
+ const lastShadow = () =>
24
+ trackedEvents.filter((e) => e.event === "sdk_resolver_shadow").at(-1)?.props;
25
+
26
+ // ─── Flag mock ────────────────────────────────────────────────────────────────
27
+
28
+ // manualEditingAvailability reads env at module load time, so we mock the
29
+ // module to control flag values per test group.
30
+ // Default false in tests so shadow is opt-in per test (real default is true).
31
+ const mockFlags = { STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false };
32
+ vi.mock("../components/editor/manualEditingAvailability", () => ({
33
+ get STUDIO_SDK_RESOLVER_SHADOW_ENABLED() {
34
+ return mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED;
35
+ },
36
+ get STUDIO_SDK_CUTOVER_ENABLED() {
37
+ return false;
38
+ },
39
+ }));
40
+
41
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
42
+
43
+ const BASE_HTML = /* html */ `<!DOCTYPE html>
44
+ <html><body>
45
+ <div data-hf-id="hf-box" style="color: red; width: 100px;" data-name="box">Hello</div>
46
+ </body></html>`;
47
+
48
+ // Prevents setStyle from applying so the read-back value differs from expected.
49
+ // Used in C9 and D11 to simulate a silent SDK value-dispatch bug.
50
+ async function makePoisonedStyleSession() {
51
+ const session = await openComposition(BASE_HTML);
52
+ const origDispatch = session.dispatch.bind(session);
53
+ session.dispatch = (op) => {
54
+ if (typeof op === "object" && "type" in op && op.type === "setStyle") return;
55
+ origDispatch(op);
56
+ };
57
+ return session;
58
+ }
59
+
60
+ // ─── A. Flag gating ───────────────────────────────────────────────────────────
61
+
62
+ describe("A. Flag gating", () => {
63
+ it("A1: flag off → no telemetry, SDK path not touched", async () => {
64
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
65
+ const session = await openComposition(BASE_HTML);
66
+ const spy = vi.spyOn(session, "getElement");
67
+ runResolverShadow(session, "hf-box", [
68
+ { type: "inline-style", property: "color", value: "blue" },
69
+ ]);
70
+ expect(trackedEvents).toHaveLength(0);
71
+ expect(spy).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("A2: flag on + divergence → emits exactly one telemetry event", async () => {
75
+ // runResolverShadow emits only on divergence, so force one (poisoned dispatch
76
+ // → value_mismatch). A parity edit is silent (see B-parity-silent).
77
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
78
+ const session = await makePoisonedStyleSession();
79
+ runResolverShadow(session, "hf-box", [
80
+ { type: "inline-style", property: "color", value: "blue" },
81
+ ]);
82
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1);
83
+ });
84
+
85
+ it("A2b: flag on + parity → emits nothing (divergence-only)", async () => {
86
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
87
+ const session = await openComposition(BASE_HTML);
88
+ runResolverShadow(session, "hf-box", [
89
+ { type: "inline-style", property: "color", value: "blue" },
90
+ ]);
91
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
92
+ });
93
+
94
+ it("A3: shadow depends ONLY on shadow flag, not on STUDIO_SDK_CUTOVER_ENABLED", async () => {
95
+ // The mock always returns STUDIO_SDK_CUTOVER_ENABLED=false. Use a divergence
96
+ // (poisoned session) so the flag-on case emits; flag-off must stay silent.
97
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
98
+ const session = await makePoisonedStyleSession();
99
+ runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
100
+ expect(trackedEvents).toHaveLength(0); // cutover off, shadow off → no event
101
+
102
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
103
+ runResolverShadow(session, "hf-box", [{ type: "inline-style", property: "color", value: "x" }]);
104
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); // shadow on regardless
105
+ });
106
+
107
+ it("A4: null/undefined hfId is a safe no-op (no event, no throw)", async () => {
108
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
109
+ const session = await openComposition(BASE_HTML);
110
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
111
+ expect(() => runResolverShadow(session, null, ops)).not.toThrow();
112
+ expect(() => runResolverShadow(session, undefined, ops)).not.toThrow();
113
+ expect(trackedEvents).toHaveLength(0);
114
+ });
115
+ });
116
+
117
+ // ─── B. Telemetry-only (no side effects on real write) ────────────────────────
118
+
119
+ describe("B. Telemetry-only / no side effects", () => {
120
+ it("B4: no disk write — shadow never calls writeProjectFile", async () => {
121
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
122
+ const writeProjectFile = vi.fn();
123
+ const session = await openComposition(BASE_HTML);
124
+ runResolverShadow(session, "hf-box", [
125
+ { type: "inline-style", property: "color", value: "blue" },
126
+ ]);
127
+ // writeProjectFile is a deps-level function not in scope here; verify by
128
+ // checking sdkResolverShadowCheck itself never touches it — it's not passed
129
+ // in at all, so any call would be a TypeError at runtime.
130
+ expect(writeProjectFile).not.toHaveBeenCalled();
131
+ });
132
+
133
+ it("B5: the LIVE session is restored after the check (cutover before===after stays correct)", async () => {
134
+ // The session is shared with the cutover path. The shadow dispatches into it
135
+ // to read values back, then MUST undo those mutations — otherwise the edit is
136
+ // pre-applied and the following sdkCutoverPersist sees before === after and
137
+ // silently falls back to the server path.
138
+ const session = await openComposition(BASE_HTML);
139
+ expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
140
+
141
+ const mismatches = sdkResolverShadowCheck(session, "hf-box", [
142
+ { type: "inline-style", property: "color", value: "blue" },
143
+ ]);
144
+ expect(mismatches).toHaveLength(0); // SDK applied blue == expected → parity
145
+
146
+ // …but the session is back to its pre-check state, NOT left on "blue".
147
+ expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red");
148
+ });
149
+
150
+ it("B5b: a real cutover-style serialize diff survives a preceding shadow run", async () => {
151
+ // End-to-end of the bug: shadow runs, THEN a cutover-style before/dispatch/
152
+ // after still produces a diff (proving shadow left no residue).
153
+ const session = await openComposition(BASE_HTML);
154
+ sdkResolverShadowCheck(session, "hf-box", [
155
+ { type: "inline-style", property: "color", value: "blue" },
156
+ ]);
157
+ const before = session.serialize();
158
+ session.dispatch({ type: "setStyle", target: "hf-box", styles: { color: "blue" } });
159
+ const after = session.serialize();
160
+ expect(after).not.toBe(before); // cutover would write, not fall back
161
+ });
162
+
163
+ it("B6: exception inside shadow never propagates to caller", async () => {
164
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
165
+ const session = await openComposition(BASE_HTML);
166
+ session.dispatch = () => {
167
+ throw new Error("sdk exploded");
168
+ };
169
+ const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }];
170
+ expect(() => runResolverShadow(session, "hf-box", ops)).not.toThrow();
171
+ // A dispatch_error mismatch is still emitted (via telemetry)
172
+ const ev = lastShadow();
173
+ expect(ev).toBeDefined();
174
+ expect(ev?.mismatchCount).toBe(1);
175
+ });
176
+ });
177
+
178
+ // ─── C. Resolver-parity detection ────────────────────────────────────────────
179
+
180
+ describe("C. Resolver-parity detection", () => {
181
+ it("C7: match → mismatchCount 0", async () => {
182
+ const session = await openComposition(BASE_HTML);
183
+ const mismatches = sdkResolverShadowCheck(session, "hf-box", [
184
+ { type: "inline-style", property: "color", value: "blue" },
185
+ ]);
186
+ expect(mismatches).toHaveLength(0);
187
+ });
188
+
189
+ it("C8: element_not_found fires when SDK resolver returns null (v0.6.110 class)", () => {
190
+ // Simulate the regression: SDK session cannot resolve the hfId the server
191
+ // would address (e.g. scoped-id mismatch, resolver bug).
192
+ const session = { getElement: () => null } as unknown as Parameters<
193
+ typeof sdkResolverShadowCheck
194
+ >[0];
195
+ const mismatches = sdkResolverShadowCheck(
196
+ session as unknown as Parameters<typeof sdkResolverShadowCheck>[0],
197
+ "hf-box",
198
+ [{ type: "inline-style", property: "color", value: "red" }],
199
+ );
200
+ expect(mismatches).toHaveLength(1);
201
+ expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
202
+ kind: "element_not_found",
203
+ hfId: "hf-box",
204
+ });
205
+ });
206
+
207
+ it("C8 inverse: no element_not_found when SDK resolves (server also resolves)", async () => {
208
+ const session = await openComposition(BASE_HTML);
209
+ const mismatches = sdkResolverShadowCheck(session, "hf-box", [
210
+ { type: "inline-style", property: "color", value: "blue" },
211
+ ]);
212
+ expect(mismatches.some((m) => m.kind === "element_not_found")).toBe(false);
213
+ });
214
+
215
+ it("C9: value_mismatch when dispatch yields different value than expected", async () => {
216
+ const session = await makePoisonedStyleSession();
217
+ const mismatches = sdkResolverShadowCheck(session, "hf-box", [
218
+ { type: "inline-style", property: "color", value: "blue" },
219
+ ]);
220
+ expect(mismatches).toHaveLength(1);
221
+ expect(mismatches[0]).toMatchObject<SdkResolverMismatch>({
222
+ kind: "value_mismatch",
223
+ hfId: "hf-box",
224
+ property: "color",
225
+ expected: "blue",
226
+ });
227
+ });
228
+
229
+ it("C10: unmappable op type produces no mismatch (excluded, not flagged)", async () => {
230
+ const session = await openComposition(BASE_HTML);
231
+ // "unknown-op" is not in MAPPED_OP_TYPES, so it must be silently excluded.
232
+ const ops = [{ type: "unknown-op", property: "x", value: "y" }] as unknown as PatchOperation[];
233
+ const mismatches = sdkResolverShadowCheck(session, "hf-box", ops);
234
+ expect(mismatches).toHaveLength(0);
235
+ });
236
+ });
237
+
238
+ // ─── D. Redaction ─────────────────────────────────────────────────────────────
239
+
240
+ describe("D. Redaction", () => {
241
+ it("D11: telemetry payload carries kind/hfId/count but NOT raw style value or text", async () => {
242
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
243
+ const session = await makePoisonedStyleSession();
244
+ const sensitiveValue = "rgba(255, 0, 0, 0.5)";
245
+ runResolverShadow(session, "hf-box", [
246
+ { type: "inline-style", property: "color", value: sensitiveValue },
247
+ ]);
248
+ const ev = lastShadow();
249
+ expect(ev).toBeDefined();
250
+ expect(ev?.mismatchCount).toBe(1);
251
+ // The raw sensitive value must NOT appear in the serialized mismatches
252
+ const serialized = JSON.stringify(ev?.mismatches ?? "");
253
+ expect(serialized).not.toContain(sensitiveValue);
254
+ // But the kind and hfId must be present
255
+ expect(serialized).toContain("value_mismatch");
256
+ expect(serialized).toContain("hf-box");
257
+ });
258
+
259
+ it("D11: text-content value is fully redacted (replaced with length marker)", async () => {
260
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
261
+ const session = await openComposition(BASE_HTML);
262
+ const origDispatch = session.dispatch.bind(session);
263
+ // Prevent setText from applying so text value differs
264
+ session.dispatch = (op) => {
265
+ if (typeof op === "object" && "type" in op && op.type === "setText") return;
266
+ origDispatch(op);
267
+ };
268
+ const secretText = "confidential user content";
269
+ runResolverShadow(session, "hf-box", [
270
+ { type: "text-content", property: "text", value: secretText },
271
+ ]);
272
+ const ev = lastShadow();
273
+ const serialized = JSON.stringify(ev?.mismatches ?? "");
274
+ expect(serialized).not.toContain(secretText);
275
+ expect(serialized).toContain("[redacted len=");
276
+ });
277
+ });
278
+
279
+ // ─── E. Soak gate ─────────────────────────────────────────────────────────────
280
+
281
+ describe("E. Soak gate", () => {
282
+ it("E12: zero divergences → parity-proven", () => {
283
+ expect(evaluateSoakGate(0)).toBe("parity-proven");
284
+ });
285
+
286
+ it("E12: one divergence → divergence-detected", () => {
287
+ expect(evaluateSoakGate(1)).toBe("divergence-detected");
288
+ });
289
+
290
+ it("E12: many divergences → divergence-detected", () => {
291
+ expect(evaluateSoakGate(100)).toBe("divergence-detected");
292
+ });
293
+ });
294
+
295
+ // ─── F. recordResolverParity (extended coverage: timing / delete / gsap-add) ──
296
+
297
+ describe("F. recordResolverParity", () => {
298
+ it("emits element_not_found when the SDK cannot resolve the target", async () => {
299
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
300
+ const session = await openComposition(BASE_HTML);
301
+ recordResolverParity(session, "hf-missing", "setTiming");
302
+ const ev = lastShadow();
303
+ expect(ev?.mismatchCount).toBe(1);
304
+ expect(ev?.opLabel).toBe("setTiming");
305
+ expect(JSON.stringify(ev?.mismatches)).toContain("element_not_found");
306
+ });
307
+
308
+ it("emits nothing when the target resolves (parity)", async () => {
309
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
310
+ const session = await openComposition(BASE_HTML);
311
+ recordResolverParity(session, "hf-box", "removeElement");
312
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
313
+ });
314
+
315
+ it("is a no-op (no SDK touch) when the flag is off", async () => {
316
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
317
+ const session = await openComposition(BASE_HTML);
318
+ const spy = vi.spyOn(session, "getElement");
319
+ recordResolverParity(session, "hf-missing", "setTiming");
320
+ expect(trackedEvents).toHaveLength(0);
321
+ expect(spy).not.toHaveBeenCalled();
322
+ });
323
+
324
+ it("never mutates the session (read-only resolver check)", async () => {
325
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
326
+ const session = await openComposition(BASE_HTML);
327
+ recordResolverParity(session, "hf-box", "setTiming");
328
+ expect(session.getElement("hf-box")?.inlineStyles.color).toBe("red"); // unchanged
329
+ });
330
+ });
331
+
332
+ // ─── G. recordAnimationResolverParity (GSAP animationId ops) ──────────────────
333
+
334
+ const GSAP_HTML = /* html */ `<!DOCTYPE html>
335
+ <html><body>
336
+ <div data-hf-id="hf-box" style="color: red">Hello</div>
337
+ <script>var tl = gsap.timeline({ paused: true }); tl.to("[data-hf-id=\\"hf-box\\"]", { x: 100, duration: 1 }, 0);</script>
338
+ </body></html>`;
339
+
340
+ describe("G. recordAnimationResolverParity", () => {
341
+ it("emits animation_not_found when the SDK cannot resolve the animationId", async () => {
342
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
343
+ const session = await openComposition(GSAP_HTML);
344
+ recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
345
+ const ev = lastShadow();
346
+ expect(ev?.mismatchCount).toBe(1);
347
+ expect(ev?.opLabel).toBe("setGsapTween");
348
+ expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found");
349
+ });
350
+
351
+ it("emits nothing when the animationId resolves to a located animation", async () => {
352
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true;
353
+ const session = await openComposition(GSAP_HTML);
354
+ const realId = session.getElements().flatMap((e) => [...e.animationIds])[0] ?? "";
355
+ expect(realId).not.toBe(""); // fixture has a tween on hf-box
356
+ recordAnimationResolverParity(session, realId, "removeGsapTween");
357
+ expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0);
358
+ });
359
+
360
+ it("is a no-op when the flag is off", async () => {
361
+ mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = false;
362
+ const session = await openComposition(GSAP_HTML);
363
+ recordAnimationResolverParity(session, "no-such-anim", "setGsapTween");
364
+ expect(trackedEvents).toHaveLength(0);
365
+ });
366
+ });
@@ -0,0 +1,313 @@
1
+ /**
2
+ * SDK resolver-parity tripwire (telemetry-only).
3
+ *
4
+ * Checks whether the SDK session resolves the same element id the server
5
+ * patch path would target, then optionally verifies value parity after an
6
+ * in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
7
+ *
8
+ * Headline signal: `element_not_found` — the resolver divergence class that
9
+ * caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
10
+ * this class; this tripwire exists specifically to catch it.
11
+ *
12
+ * Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
13
+ * `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
14
+ * wild telemetry; flip off / remove once resolver parity is proven).
15
+ * Telemetry-only — never writes to disk, never affects the user-visible edit.
16
+ */
17
+
18
+ import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
19
+ import type { PatchOperation } from "./sourcePatcher";
20
+ import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
21
+ import { patchOpsToSdkEditOps } from "./sdkOpMapping";
22
+ import { trackStudioEvent } from "./studioTelemetry";
23
+
24
+ // ─── Types ────────────────────────────────────────────────────────────────────
25
+
26
+ export interface SdkResolverMismatch {
27
+ kind: "element_not_found" | "value_mismatch" | "dispatch_error" | "animation_not_found";
28
+ hfId?: string;
29
+ animationId?: string;
30
+ property?: string;
31
+ expected?: string | null;
32
+ actual?: string | null | undefined;
33
+ error?: string;
34
+ }
35
+
36
+ // ─── Op helpers ───────────────────────────────────────────────────────────────
37
+
38
+ // Drop studio-internal data-hf-* markers the SDK model doesn't represent.
39
+ function isShadowableOp(op: PatchOperation): boolean {
40
+ const name =
41
+ op.type === "attribute"
42
+ ? op.property.startsWith("data-")
43
+ ? op.property
44
+ : `data-${op.property}`
45
+ : op.type === "html-attribute"
46
+ ? op.property
47
+ : null;
48
+ return name === null || !name.startsWith("data-hf-");
49
+ }
50
+
51
+ const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);
52
+
53
+ // ─── Read-back helpers ────────────────────────────────────────────────────────
54
+
55
+ function kebabToCamel(prop: string): string {
56
+ return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
57
+ }
58
+
59
+ function normalizeText(v: string | null | undefined): string | null {
60
+ if (v == null) return null;
61
+ const t = v.trim();
62
+ return t === "" ? null : t;
63
+ }
64
+
65
+ type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
66
+ type AttrMap = Record<string, string | null>;
67
+
68
+ function checkStyleOp(
69
+ op: PatchOperation,
70
+ el: FlatEl,
71
+ ): { expected: string | null; actual: string | null } {
72
+ return {
73
+ expected: op.value ?? null,
74
+ actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
75
+ };
76
+ }
77
+
78
+ function checkTextOp(
79
+ op: PatchOperation,
80
+ el: FlatEl,
81
+ ): { expected: string | null; actual: string | null } {
82
+ return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
83
+ }
84
+
85
+ function checkAttrOp(
86
+ op: PatchOperation,
87
+ el: FlatEl,
88
+ ): { property: string; expected: string | null; actual: string | null } {
89
+ const property =
90
+ op.type === "attribute"
91
+ ? op.property.startsWith("data-")
92
+ ? op.property
93
+ : `data-${op.property}`
94
+ : op.property;
95
+ return {
96
+ property,
97
+ expected: op.value ?? null,
98
+ actual: (el.attributes as AttrMap)[property] ?? null,
99
+ };
100
+ }
101
+
102
+ function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
103
+ let property: string;
104
+ let expected: string | null;
105
+ let actual: string | null;
106
+
107
+ if (op.type === "inline-style") {
108
+ property = op.property;
109
+ ({ expected, actual } = checkStyleOp(op, el));
110
+ } else if (op.type === "text-content") {
111
+ property = "text";
112
+ ({ expected, actual } = checkTextOp(op, el));
113
+ } else if (op.type === "attribute" || op.type === "html-attribute") {
114
+ ({ property, expected, actual } = checkAttrOp(op, el));
115
+ } else {
116
+ return null;
117
+ }
118
+
119
+ if (actual === expected) return null;
120
+ return { kind: "value_mismatch", hfId, property, expected, actual };
121
+ }
122
+
123
+ // ─── Core check (pure — testable without flag) ────────────────────────────────
124
+
125
+ /**
126
+ * Run the resolver shadow check against an already-open SDK session.
127
+ *
128
+ * Returns an array of mismatches (empty = parity). The value-parity check
129
+ * dispatches the ops into the session to read the result back, then UNDOES
130
+ * those mutations via the captured inverse patches before returning — the
131
+ * session ends exactly as it started. This is essential: the session is shared
132
+ * with the cutover path, and a residual shadow mutation would make the
133
+ * subsequent sdkCutoverPersist see before === after and silently fall back to
134
+ * the server path. Telemetry-only; the server path stays authoritative on disk.
135
+ *
136
+ * Exported for unit tests; call `runResolverShadow` at call sites.
137
+ */
138
+ export function sdkResolverShadowCheck(
139
+ session: Composition,
140
+ hfId: string,
141
+ ops: PatchOperation[],
142
+ ): SdkResolverMismatch[] {
143
+ if (!session.getElement(hfId)) {
144
+ return [{ kind: "element_not_found", hfId }];
145
+ }
146
+
147
+ const shadowable = ops.filter(isShadowableOp);
148
+ if (shadowable.length === 0) return [];
149
+
150
+ // Silently skip op batches containing unmapped types — not a resolver bug.
151
+ if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];
152
+
153
+ // Capture the inverse of the shadow dispatch so we can restore the session.
154
+ // `batch` fires a single PatchEvent whose `inversePatches` are already in
155
+ // reverse-apply order (session.ts reverses inside buildPatchEvent), so
156
+ // applyPatches(inverse) undoes the dispatch with no further reordering. If a
157
+ // future SDK refactor ever coalesces batch into a composite with no per-op
158
+ // inverse, this restore breaks — keep batch emitting inverse patches.
159
+ const inverse: JsonPatchOp[] = [];
160
+ const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
161
+ // restore() runs in `finally` so the patch listener is always removed and the
162
+ // session is always undone — even if checkOpValue throws between dispatch and
163
+ // return. A residual mutation or leaked listener on the shared session is the
164
+ // exact cutover-coupling failure mode this module exists to avoid.
165
+ try {
166
+ try {
167
+ const editOps = patchOpsToSdkEditOps(hfId, shadowable);
168
+ session.batch(() => {
169
+ for (const op of editOps) session.dispatch(op);
170
+ });
171
+ } catch (err) {
172
+ return [{ kind: "dispatch_error", hfId, error: String(err) }];
173
+ }
174
+
175
+ const el = session.getElement(hfId);
176
+ if (!el) return [{ kind: "element_not_found", hfId }];
177
+
178
+ return shadowable
179
+ .map((op) => checkOpValue(op, el, hfId))
180
+ .filter((m): m is SdkResolverMismatch => m !== null);
181
+ } finally {
182
+ stopCapture();
183
+ if (inverse.length > 0) session.applyPatches(inverse);
184
+ }
185
+ }
186
+
187
+ // ─── Telemetry ────────────────────────────────────────────────────────────────
188
+
189
+ // Redact all user-content values before telemetry: style values and text both
190
+ // carry user data. Keep only the length so we can detect truncation without
191
+ // leaking the actual bytes.
192
+ function redactValue(value: string | null | undefined): string | null | undefined {
193
+ if (value == null) return value;
194
+ return `[redacted len=${value.length}]`;
195
+ }
196
+
197
+ function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
198
+ return mismatches.map((m) => ({
199
+ ...m,
200
+ expected: redactValue(m.expected),
201
+ actual: redactValue(m.actual),
202
+ }));
203
+ }
204
+
205
+ /**
206
+ * Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
207
+ * No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
208
+ * Never throws — any exception inside the shadow is swallowed.
209
+ *
210
+ * Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
211
+ * the session to read values back, then undoes those mutations before returning
212
+ * (see below). The session is shared with the cutover path, so it MUST end the
213
+ * call exactly as it started.
214
+ */
215
+ export function runResolverShadow(
216
+ session: Composition,
217
+ hfId: string | null | undefined,
218
+ ops: PatchOperation[],
219
+ ): void {
220
+ if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
221
+ if (!hfId) return;
222
+ try {
223
+ const mismatches = sdkResolverShadowCheck(session, hfId, ops);
224
+ // Emit only on divergence — parity is silent, matching recordResolverParity
225
+ // and recordAnimationResolverParity. Otherwise this fires a PostHog event on
226
+ // every style/text/attr edit (the editor's chattiest path) at default-ON.
227
+ if (mismatches.length === 0) return;
228
+ trackStudioEvent("sdk_resolver_shadow", {
229
+ hfId,
230
+ mismatchCount: mismatches.length,
231
+ mismatches: JSON.stringify(redactMismatches(mismatches)),
232
+ });
233
+ } catch {
234
+ // never propagate from the shadow path
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Record element-resolution parity for an element-targeted op WITHOUT
240
+ * dispatching. Read-only: emits a single `element_not_found` event when the SDK
241
+ * can't resolve a target the server path is addressing. This extends the
242
+ * tripwire beyond the DOM-edit path (runResolverShadow) to the other
243
+ * element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
244
+ * the headline resolver signal, without the cost/mutation of a value check.
245
+ *
246
+ * No-op when the shadow flag is off; never throws; never mutates the session.
247
+ */
248
+ export function recordResolverParity(
249
+ session: Composition | null | undefined,
250
+ hfId: string | null | undefined,
251
+ opLabel: string,
252
+ ): void {
253
+ if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
254
+ if (!session || !hfId) return;
255
+ try {
256
+ if (session.getElement(hfId)) return; // resolves — parity, nothing to record
257
+ trackStudioEvent("sdk_resolver_shadow", {
258
+ hfId,
259
+ opLabel,
260
+ mismatchCount: 1,
261
+ mismatches: JSON.stringify([
262
+ { kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
263
+ ]),
264
+ });
265
+ } catch {
266
+ // never propagate from the shadow path
267
+ }
268
+ }
269
+
270
+ /**
271
+ * Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
272
+ * dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
273
+ * the animationId the server GSAP path is addressing — the GSAP-edit-surface
274
+ * analogue of element_not_found. The SDK's resolvable animation ids are the
275
+ * located ids attached to elements (buildAnimationIdMap), so a target absent
276
+ * from every element's animationIds is a resolver divergence.
277
+ *
278
+ * No-op when the shadow flag is off; never throws; never mutates the session.
279
+ */
280
+ export function recordAnimationResolverParity(
281
+ session: Composition | null | undefined,
282
+ animationId: string,
283
+ opLabel: string,
284
+ ): void {
285
+ if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
286
+ if (!session || !animationId) return;
287
+ try {
288
+ const resolves = session.getElements().some((el) => el.animationIds.includes(animationId));
289
+ if (resolves) return; // SDK locates the animation — parity
290
+ trackStudioEvent("sdk_resolver_shadow", {
291
+ animationId,
292
+ opLabel,
293
+ mismatchCount: 1,
294
+ mismatches: JSON.stringify([
295
+ { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
296
+ ]),
297
+ });
298
+ } catch {
299
+ // never propagate from the shadow path
300
+ }
301
+ }
302
+
303
+ // ─── Soak gate ────────────────────────────────────────────────────────────────
304
+
305
+ /**
306
+ * Evaluate the soak-gate exit criterion.
307
+ *
308
+ * A clean soak window has zero `element_not_found` divergences. When that
309
+ * condition holds, resolver parity is proven and the flag can be retired.
310
+ */
311
+ export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
312
+ return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
313
+ }