@hyperframes/studio 0.7.52 → 0.7.54

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 (39) hide show
  1. package/dist/assets/{index-DKcziwpY.js → index-CMHYjEZ5.js} +1 -1
  2. package/dist/assets/index-pRhCpGPz.js +423 -0
  3. package/dist/assets/{index-B27HFK8R.js → index-uBY329wb.js} +1 -1
  4. package/dist/{chunk-BA66NM4L.js → chunk-SOTCF4DF.js} +4 -2
  5. package/dist/chunk-SOTCF4DF.js.map +1 -0
  6. package/dist/{domEditingLayers-H7LDFIJ7.js → domEditingLayers-2ECJK24D.js} +2 -2
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +669 -338
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/editor/DomEditCropHandles.test.tsx +86 -0
  12. package/src/components/editor/DomEditCropHandles.tsx +96 -58
  13. package/src/components/editor/DomEditOverlay.test.ts +41 -0
  14. package/src/components/editor/DomEditOverlay.tsx +17 -1
  15. package/src/components/editor/domEditOverlayCrop.test.ts +137 -0
  16. package/src/components/editor/domEditOverlayCrop.ts +103 -7
  17. package/src/components/editor/domEditOverlayGestures.ts +35 -6
  18. package/src/components/editor/domEditOverlayStartGesture.ts +35 -2
  19. package/src/components/editor/domEditingDom.ts +1 -2
  20. package/src/components/editor/useDomEditOverlayGestures.ts +48 -7
  21. package/src/hooks/gsapResizeIntercept.test.ts +129 -0
  22. package/src/hooks/gsapResizeIntercept.ts +385 -0
  23. package/src/hooks/gsapRuntimeBridge.ts +3 -226
  24. package/src/hooks/gsapRuntimePatch.test.ts +63 -0
  25. package/src/hooks/gsapRuntimePatch.ts +35 -1
  26. package/src/hooks/gsapRuntimeReaders.test.ts +111 -0
  27. package/src/hooks/gsapRuntimeReaders.ts +39 -14
  28. package/src/hooks/useAnimatedPropertyCommit.test.tsx +66 -5
  29. package/src/hooks/useAnimatedPropertyCommit.ts +85 -17
  30. package/src/hooks/useGsapAwareEditing.ts +2 -5
  31. package/src/hooks/useGsapScriptCommits.test.tsx +86 -66
  32. package/src/hooks/useGsapScriptCommits.ts +27 -4
  33. package/src/utils/authoredOpacity.ts +35 -0
  34. package/src/utils/elementGsap.ts +31 -0
  35. package/src/utils/gsapSoftReload.test.ts +86 -2
  36. package/src/utils/gsapSoftReload.ts +69 -7
  37. package/dist/assets/index-v3DXednk.js +0 -423
  38. package/dist/chunk-BA66NM4L.js.map +0 -1
  39. /package/dist/{domEditingLayers-H7LDFIJ7.js.map → domEditingLayers-2ECJK24D.js.map} +0 -0
@@ -38,6 +38,27 @@ function result(over: Partial<MutationResult> = {}): MutationResult {
38
38
  return { ok: true, scriptText: "tl.set('#a',{})", ...over };
39
39
  }
40
40
 
41
+ /** The canonical drag commit options every path-decision test drives with. */
42
+ function dragOptions() {
43
+ return {
44
+ label: "drag",
45
+ softReload: true,
46
+ instantPatch: { selector: "#a", change: { kind: "set" as const, props: { x: 10 } } },
47
+ };
48
+ }
49
+
50
+ function syncDragPreview(res: MutationResult, reloadPreview: () => void) {
51
+ applyPreviewSync(FAKE_IFRAME, res, dragOptions(), reloadPreview);
52
+ }
53
+
54
+ function expectSoftReloadedWith(onAsyncFailure: unknown, authoredHtml: string | undefined) {
55
+ expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", {
56
+ onAsyncFailure,
57
+ currentTimeOverride: 0,
58
+ authoredHtml,
59
+ });
60
+ }
61
+
41
62
  describe("applyPreviewSync", () => {
42
63
  beforeEach(() => {
43
64
  patchRuntimeTweenInPlace.mockReset();
@@ -49,16 +70,7 @@ describe("applyPreviewSync", () => {
49
70
  patchRuntimeTweenInPlace.mockReturnValue(true);
50
71
  const reloadPreview = vi.fn();
51
72
 
52
- applyPreviewSync(
53
- FAKE_IFRAME,
54
- result(),
55
- {
56
- label: "drag",
57
- softReload: true,
58
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
59
- },
60
- reloadPreview,
61
- );
73
+ syncDragPreview(result(), reloadPreview);
62
74
 
63
75
  expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
64
76
  kind: "set",
@@ -73,20 +85,11 @@ describe("applyPreviewSync", () => {
73
85
  applySoftReload.mockReturnValue("applied");
74
86
  const reloadPreview = vi.fn();
75
87
 
76
- applyPreviewSync(
77
- FAKE_IFRAME,
78
- result({ scriptText: "SCRIPT" }),
79
- {
80
- label: "drag",
81
- softReload: true,
82
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
83
- },
84
- reloadPreview,
85
- );
88
+ syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
86
89
 
87
90
  // reloadPreview is wired as onAsyncFailure (3rd arg) so a MotionPath-plugin
88
91
  // CDN load failure escalates to a full reload — but it is NOT called eagerly.
89
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
92
+ expectSoftReloadedWith(reloadPreview, undefined);
90
93
  expect(reloadPreview).not.toHaveBeenCalled();
91
94
  // A successful instant patch is the fast path; here it missed → fallback event.
92
95
  expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -100,20 +103,11 @@ describe("applyPreviewSync", () => {
100
103
  applySoftReload.mockReturnValue("verify-failed");
101
104
  const reloadPreview = vi.fn();
102
105
 
103
- applyPreviewSync(
104
- FAKE_IFRAME,
105
- result({ scriptText: "SCRIPT" }),
106
- {
107
- label: "drag",
108
- softReload: true,
109
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
110
- },
111
- reloadPreview,
112
- );
106
+ syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
113
107
 
114
108
  // U4: "verify-failed" is the TRANSIENT empty-timeline window — the live state
115
109
  // is correct, so we must NOT escalate to a full reload.
116
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
110
+ expectSoftReloadedWith(reloadPreview, undefined);
117
111
  expect(reloadPreview).not.toHaveBeenCalled();
118
112
  // Telemetry records the suppressed transient (escalated: false).
119
113
  expect(trackStudioEvent).toHaveBeenCalledWith(
@@ -131,19 +125,10 @@ describe("applyPreviewSync", () => {
131
125
  applySoftReload.mockReturnValue("cannot-soft-reload");
132
126
  const reloadPreview = vi.fn();
133
127
 
134
- applyPreviewSync(
135
- FAKE_IFRAME,
136
- result({ scriptText: "SCRIPT" }),
137
- {
138
- label: "drag",
139
- softReload: true,
140
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
141
- },
142
- reloadPreview,
143
- );
128
+ syncDragPreview(result({ scriptText: "SCRIPT" }), reloadPreview);
144
129
 
145
130
  // Structural failure: the preview is genuinely stale/broken → full reload.
146
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
131
+ expectSoftReloadedWith(reloadPreview, undefined);
147
132
  expect(reloadPreview).toHaveBeenCalledTimes(1);
148
133
  expect(trackStudioEvent).toHaveBeenCalledWith(
149
134
  "gsap_soft_reload_outcome",
@@ -167,7 +152,7 @@ describe("applyPreviewSync", () => {
167
152
  );
168
153
 
169
154
  expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
170
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
155
+ expectSoftReloadedWith(reloadPreview, undefined);
171
156
  expect(reloadPreview).not.toHaveBeenCalled();
172
157
  // "applied" emits no telemetry (only the failure paths do).
173
158
  expect(trackStudioEvent).not.toHaveBeenCalled();
@@ -185,7 +170,7 @@ describe("applyPreviewSync", () => {
185
170
  );
186
171
 
187
172
  // onAsyncFailure is wired, but the transient result does not trigger it.
188
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
173
+ expectSoftReloadedWith(reloadPreview, undefined);
189
174
  expect(reloadPreview).not.toHaveBeenCalled();
190
175
  expect(trackStudioEvent).toHaveBeenCalledWith(
191
176
  "gsap_soft_reload_outcome",
@@ -204,7 +189,7 @@ describe("applyPreviewSync", () => {
204
189
  reloadPreview,
205
190
  );
206
191
 
207
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", reloadPreview, 0);
192
+ expectSoftReloadedWith(reloadPreview, undefined);
208
193
  expect(reloadPreview).toHaveBeenCalledTimes(1);
209
194
  expect(trackStudioEvent).toHaveBeenCalledWith(
210
195
  "gsap_soft_reload_outcome",
@@ -291,6 +276,57 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
291
276
  }
292
277
 
293
278
  describe("runCommit — instantPatch wiring", () => {
279
+ it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
280
+ patchRuntimeTweenInPlace.mockReturnValue(true);
281
+ mockFetchResult({ changed: false });
282
+ const deps = renderCommitHook();
283
+
284
+ await act(async () => {
285
+ await deps.api.commitMutation(
286
+ selection,
287
+ { type: "update-property", property: "y", value: 311 },
288
+ {
289
+ label: "Move layer",
290
+ softReload: true,
291
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
292
+ },
293
+ );
294
+ });
295
+
296
+ // The file already matched (changed:false) but the runtime patch deferred
297
+ // from the paired first commit must still land.
298
+ expect(patchRuntimeTweenInPlace).toHaveBeenCalledWith(FAKE_IFRAME, "#a", {
299
+ kind: "set",
300
+ props: { x: 485, y: 311 },
301
+ });
302
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
303
+ });
304
+
305
+ it("no-op commit whose instant patch MISSES soft-reloads (never full-reloads)", async () => {
306
+ // Server contract: gsap-mutations returns scriptText on EVERY response,
307
+ // including changed:false — so the fallback re-runs the identical script
308
+ // ("applied") instead of escalating a genuine no-op to a full reload.
309
+ patchRuntimeTweenInPlace.mockReturnValue(false);
310
+ applySoftReload.mockReturnValue("applied");
311
+ mockFetchResult({ changed: false });
312
+ const deps = renderCommitHook();
313
+
314
+ await act(async () => {
315
+ await deps.api.commitMutation(
316
+ selection,
317
+ { type: "update-property", property: "y", value: 311 },
318
+ {
319
+ label: "Move layer",
320
+ softReload: true,
321
+ instantPatch: { selector: "#a", change: { kind: "set", props: { x: 485, y: 311 } } },
322
+ },
323
+ );
324
+ });
325
+
326
+ expectSoftReloadedWith(deps.reloadPreview, "AFTER");
327
+ expect(deps.reloadPreview).not.toHaveBeenCalled();
328
+ });
329
+
294
330
  beforeEach(() => {
295
331
  patchRuntimeTweenInPlace.mockReset();
296
332
  applySoftReload.mockReset();
@@ -308,15 +344,7 @@ describe("runCommit — instantPatch wiring", () => {
308
344
  const deps = renderCommitHook();
309
345
 
310
346
  await act(async () => {
311
- await deps.api.commitMutation(
312
- selection,
313
- { x: 10 },
314
- {
315
- label: "drag",
316
- softReload: true,
317
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
318
- },
319
- );
347
+ await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
320
348
  });
321
349
 
322
350
  expect(fetch).toHaveBeenCalledTimes(1); // source mutation persisted
@@ -333,19 +361,11 @@ describe("runCommit — instantPatch wiring", () => {
333
361
  const deps = renderCommitHook();
334
362
 
335
363
  await act(async () => {
336
- await deps.api.commitMutation(
337
- selection,
338
- { x: 10 },
339
- {
340
- label: "drag",
341
- softReload: true,
342
- instantPatch: { selector: "#a", change: { kind: "set", props: { x: 10 } } },
343
- },
344
- );
364
+ await deps.api.commitMutation(selection, { x: 10 }, dragOptions());
345
365
  });
346
366
 
347
367
  expect(fetch).toHaveBeenCalledTimes(1);
348
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
368
+ expectSoftReloadedWith(deps.reloadPreview, "AFTER");
349
369
  expect(deps.reloadPreview).not.toHaveBeenCalled();
350
370
  expect(deps.onCacheInvalidate).toHaveBeenCalledTimes(1);
351
371
  });
@@ -360,7 +380,7 @@ describe("runCommit — instantPatch wiring", () => {
360
380
  });
361
381
 
362
382
  expect(patchRuntimeTweenInPlace).not.toHaveBeenCalled();
363
- expect(applySoftReload).toHaveBeenCalledWith(FAKE_IFRAME, "SCRIPT", deps.reloadPreview, 0);
383
+ expectSoftReloadedWith(deps.reloadPreview, "AFTER");
364
384
  expect(deps.reloadPreview).not.toHaveBeenCalled();
365
385
  });
366
386
  });
@@ -66,12 +66,17 @@ function softReloadOrEscalate(
66
66
  scriptText: string,
67
67
  reloadPreview: () => void,
68
68
  origin: "preview_sync" | "sdk_refresh",
69
+ authoredHtml?: string,
69
70
  ): void {
70
71
  // Seek the rebuilt timeline to the studio's own authoritative scrub position,
71
72
  // not the iframe's raw `__player.getTime()` — see the comment in
72
73
  // applySoftReload for why the two can desync after a keyframe-node drag.
73
74
  const currentTime = usePlayerStore.getState().currentTime;
74
- const result: SoftReloadResult = applySoftReload(iframe, scriptText, reloadPreview, currentTime);
75
+ const result: SoftReloadResult = applySoftReload(iframe, scriptText, {
76
+ onAsyncFailure: reloadPreview,
77
+ currentTimeOverride: currentTime,
78
+ authoredHtml,
79
+ });
75
80
  if (result === "applied") return;
76
81
  trackStudioEvent("gsap_soft_reload_outcome", {
77
82
  origin,
@@ -116,7 +121,13 @@ export function applyPreviewSync(
116
121
  // already correct on screen, and a remount re-flashes the WebGL context AND
117
122
  // re-inlines subcomps (reverting their keyframes). The async MotionPath-plugin
118
123
  // load failure escalates separately via `onAsyncFailure`.
119
- softReloadOrEscalate(iframe, result.scriptText, reloadPreview, "preview_sync");
124
+ softReloadOrEscalate(
125
+ iframe,
126
+ result.scriptText,
127
+ reloadPreview,
128
+ "preview_sync",
129
+ result.after ?? undefined,
130
+ );
120
131
  } else {
121
132
  reloadPreview();
122
133
  }
@@ -149,7 +160,19 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
149
160
  if (options.skipReload) return;
150
161
  throw error;
151
162
  }
152
- if (result.changed === false) return;
163
+ if (result.changed === false) {
164
+ // The FILE already matched, but a deferred instant patch may still be
165
+ // owed to the RUNTIME: paired commits (x with skipReload, then y carrying
166
+ // the patch for both) rely on the SECOND commit to sync the preview — if
167
+ // that half happens to be a no-op (a purely-horizontal drag or resize
168
+ // compensation), returning here would leave the runtime showing the old
169
+ // value while the file holds the new one. Patching in place is idempotent
170
+ // when the values truly match everywhere.
171
+ if (!options.skipReload && options.instantPatch) {
172
+ applyPreviewSync(previewIframeRef.current, result, options, reloadPreview);
173
+ }
174
+ return;
175
+ }
153
176
  domEditSaveTimestampRef.current = Date.now();
154
177
  if (result.before != null && result.after != null) {
155
178
  await editHistory.recordEdit({ label: options.label, kind: "manual", coalesceKey: options.coalesceKey, files: { [targetPath]: { before: result.before, after: result.after } } });
@@ -196,7 +219,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
196
219
  // plugin-CDN load error genuinely breaks the iframe → full reload. Per U4, a
197
220
  // synchronous "verify-failed" (transient empty __timelines) does NOT escalate,
198
221
  // but a "cannot-soft-reload" (structural failure) does.
199
- softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh");
222
+ softReloadOrEscalate(previewIframeRef.current, script, reloadPreview, "sdk_refresh", after);
200
223
  } else {
201
224
  reloadPreview();
202
225
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Authored-opacity contract, studio side. The runtime stamps every graded
3
+ * element's authored inline opacity at document parse time (see
4
+ * installAuthoredOpacityCapture in @hyperframes/core); studio code that makes
5
+ * GSAP re-initialize tweens (soft reload, in-place patches) restores it so
6
+ * re-captures never bake a runtime transient in as a tween bound.
7
+ */
8
+ import { COLOR_GRADING_AUTHORED_OPACITY_ATTR } from "@hyperframes/core/color-grading";
9
+
10
+ interface AttributeReader {
11
+ getAttribute(name: string): string | null;
12
+ }
13
+
14
+ /**
15
+ * The stamped authored inline opacity. Three-state:
16
+ * "0.98" — the authored value; "" — captured, authored none;
17
+ * null — never captured (unknown).
18
+ * Duck-typed so iframe-realm elements (no shared HTMLElement) work.
19
+ */
20
+ export function readStampedAuthoredOpacity(element: AttributeReader): string | null {
21
+ return element.getAttribute(COLOR_GRADING_AUTHORED_OPACITY_ATTR);
22
+ }
23
+
24
+ /**
25
+ * Write an authored inline opacity back: "" removes the property, a value sets
26
+ * it. Priority-lossy by design: the capture reads `style.opacity` (value only)
27
+ * and the write sets no priority, so an authored `opacity: X !important`
28
+ * round-trips as `opacity: X`. The only `!important` opacity in the pipeline
29
+ * is the color-grading runtime hide — a transient this contract exists to
30
+ * discard — and authored compositions don't `!important` their opacity.
31
+ */
32
+ export function applyAuthoredInlineOpacity(style: CSSStyleDeclaration, authored: string): void {
33
+ if (authored === "") style.removeProperty("opacity");
34
+ else style.setProperty("opacity", authored);
35
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * GSAP access through an ELEMENT'S OWN window (the preview iframe's runtime),
3
+ * not the studio window. This is the single way studio gesture code touches an
4
+ * iframe element's GSAP position outside the commit pipeline — the resize
5
+ * anchor pin (apply + restore) and the post-commit live correction. The commit
6
+ * pipeline itself stays the owner of persisted values.
7
+ */
8
+ type ElementGsapWindow = Window & {
9
+ gsap?: {
10
+ set?: (target: Element, vars: Record<string, number>) => void;
11
+ getProperty?: (target: Element, prop: string) => unknown;
12
+ };
13
+ };
14
+
15
+ function gsapOf(element: HTMLElement): ElementGsapWindow["gsap"] | undefined {
16
+ return (element.ownerDocument.defaultView as ElementGsapWindow | null)?.gsap;
17
+ }
18
+
19
+ /** Set the element's GSAP x/y. Returns false when no runtime is reachable. */
20
+ export function setElementGsapPosition(element: HTMLElement, x: number, y: number): boolean {
21
+ const gsap = gsapOf(element);
22
+ if (!gsap?.set) return false;
23
+ gsap.set(element, { x, y });
24
+ return true;
25
+ }
26
+
27
+ /** The element's GSAP numeric property, or null when unreadable. */
28
+ export function readElementGsapNumber(element: HTMLElement, prop: string): number | null {
29
+ const value = Number(gsapOf(element)?.getProperty?.(element, prop));
30
+ return Number.isFinite(value) ? value : null;
31
+ }
@@ -107,7 +107,7 @@ describe("applySoftReload", () => {
107
107
  // async commit resolves. The rebuilt timeline must re-seek to the caller's
108
108
  // value, not the iframe's possibly-stale one.
109
109
  const { iframe, contentWindow } = buildMockIframe();
110
- const result = applySoftReload(iframe, SCRIPT_TEXT, undefined, 0);
110
+ const result = applySoftReload(iframe, SCRIPT_TEXT, { currentTimeOverride: 0 });
111
111
  expect(result).toBe("applied");
112
112
  expect(contentWindow.__player.seek).toHaveBeenCalledWith(0);
113
113
  });
@@ -244,7 +244,7 @@ describe("applySoftReload", () => {
244
244
  (iframe.contentDocument as unknown as { head: unknown }).head = head;
245
245
 
246
246
  const onAsyncFailure = vi.fn();
247
- const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, onAsyncFailure);
247
+ const result = applySoftReload(iframe, MOTION_PATH_SCRIPT_TEXT, { onAsyncFailure });
248
248
 
249
249
  // Optimistically "applied" (script will run once the plugin loads) — and the
250
250
  // script has NOT executed yet, so the timeline isn't rebound synchronously.
@@ -363,3 +363,87 @@ describe("ensureMotionPathPluginLoaded", () => {
363
363
  expect(appendedScripts).toHaveLength(2);
364
364
  });
365
365
  });
366
+
367
+ // The authored-opacity restore: before the script re-runs (and its tweens
368
+ // re-capture bounds), every animated element's inline opacity must be put back
369
+ // to its AUTHORED value — from the after-write file HTML when provided, else
370
+ // from the parse-time stamp. Otherwise a runtime transient (the color-grading
371
+ // hide's 0, a mid-flight tween value) becomes a permanent tween bound.
372
+ describe("applySoftReload authored-opacity restore", () => {
373
+ function buildIframeWithTarget(el: HTMLElement, overrides: Record<string, unknown> = {}) {
374
+ const scriptEl = document.createElement("script");
375
+ scriptEl.textContent =
376
+ 'const tl = gsap.timeline({ paused: true }); tl.to("#box", { opacity: 0.5 });';
377
+ const tl = {
378
+ kill: vi.fn(),
379
+ pause: vi.fn(),
380
+ getChildren: () => [{ targets: () => [el] }],
381
+ };
382
+ const contentWindow = {
383
+ gsap: { timeline: vi.fn(), set: vi.fn() },
384
+ __hfForceTimelineRebind: vi.fn(),
385
+ __timelines: { root: tl } as Record<string, unknown>,
386
+ __player: { getTime: () => 2.0, seek: vi.fn() },
387
+ __hfStudioManualEditsApply: vi.fn(),
388
+ ...overrides,
389
+ };
390
+ const container = document.createElement("div");
391
+ container.appendChild(scriptEl);
392
+ // Intercept only POST-SETUP appends: simulate the re-run script
393
+ // repopulating __timelines (as in buildMockIframe).
394
+ const realAppendChild = container.appendChild.bind(container);
395
+ container.appendChild = <T extends Node>(node: T): T => {
396
+ const result = realAppendChild(node);
397
+ if (node instanceof HTMLScriptElement && node.textContent?.includes("gsap.timeline")) {
398
+ contentWindow.__timelines.root = { kill: vi.fn(), pause: vi.fn() };
399
+ }
400
+ return result;
401
+ };
402
+ const contentDocument = {
403
+ querySelectorAll: (sel: string) => (sel === "script:not([src])" ? [scriptEl] : []),
404
+ createElement: (tag: string) => document.createElement(tag),
405
+ body: container,
406
+ head: document.createElement("div"),
407
+ };
408
+ return { iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement };
409
+ }
410
+
411
+ /** Run one restore cycle over `el` and return the final inline opacity. */
412
+ function restoreOpacity(el: HTMLElement, authoredHtml?: string): string {
413
+ const { iframe } = buildIframeWithTarget(el);
414
+ expect(applySoftReload(iframe, SCRIPT_TEXT, authoredHtml ? { authoredHtml } : {})).toBe(
415
+ "applied",
416
+ );
417
+ return el.style.getPropertyValue("opacity");
418
+ }
419
+
420
+ it("restores opacity from the after-write HTML (matched by data-hf-id)", () => {
421
+ const el = document.createElement("img");
422
+ el.setAttribute("data-hf-id", "hf-1");
423
+ el.style.setProperty("opacity", "0", "important"); // the grading hide
424
+
425
+ const opacity = restoreOpacity(
426
+ el,
427
+ '<html><body><img data-hf-id="hf-1" style="opacity: 0.98"></body></html>',
428
+ );
429
+
430
+ expect(opacity).toBe("0.98");
431
+ expect(el.style.getPropertyPriority("opacity")).toBe("");
432
+ });
433
+
434
+ it("falls back to the parse-time stamp when no after-write HTML is given", () => {
435
+ const el = document.createElement("img");
436
+ el.setAttribute("data-hf-authored-opacity", "0.75");
437
+ el.style.opacity = "0.123"; // mid-flight tween transient
438
+
439
+ expect(restoreOpacity(el)).toBe("0.75");
440
+ });
441
+
442
+ it("an empty stamp (authored none) removes the inline opacity", () => {
443
+ const el = document.createElement("img");
444
+ el.setAttribute("data-hf-authored-opacity", "");
445
+ el.style.opacity = "0";
446
+
447
+ expect(restoreOpacity(el)).toBe("");
448
+ });
449
+ });
@@ -1,3 +1,6 @@
1
+ import { COLOR_GRADING_SOURCE_HIDDEN_ATTR } from "@hyperframes/core/color-grading";
2
+ import { applyAuthoredInlineOpacity, readStampedAuthoredOpacity } from "./authoredOpacity";
3
+
1
4
  type IframeWindow = Window & {
2
5
  __timelines?: Record<string, { kill?: () => void; pause?: () => void }>;
3
6
  __player?: { getTime?: () => number; seek?: (t: number) => void };
@@ -171,12 +174,21 @@ export type SoftReloadResult = "applied" | "verify-failed" | "cannot-soft-reload
171
174
  * caller should perform a full reload to recover. It never fires on the
172
175
  * synchronous paths.
173
176
  */
177
+ export interface SoftReloadOptions {
178
+ /** Escalation for async plugin-load failures (e.g. MotionPath CDN error). */
179
+ onAsyncFailure?: () => void;
180
+ /** Seek target for the rebuilt timeline; defaults to the iframe player time. */
181
+ currentTimeOverride?: number;
182
+ /** After-write file HTML — the primary source for authored-opacity restore. */
183
+ authoredHtml?: string;
184
+ }
185
+
174
186
  export function applySoftReload(
175
187
  iframe: HTMLIFrameElement | null,
176
188
  scriptText: string,
177
- onAsyncFailure?: () => void,
178
- currentTimeOverride?: number,
189
+ options: SoftReloadOptions = {},
179
190
  ): SoftReloadResult {
191
+ const { onAsyncFailure, currentTimeOverride, authoredHtml } = options;
180
192
  if (!iframe || !scriptText) return "cannot-soft-reload";
181
193
 
182
194
  const win = iframe.contentWindow as IframeWindow | null;
@@ -227,6 +239,36 @@ export function applySoftReload(
227
239
  // full iframe reload that destroys the very WebGL context we're preserving.
228
240
  let deferredToAsync = false;
229
241
 
242
+ // Authored-opacity resolution for the restore loop below. Three-state:
243
+ // "0.98" — the element's authored inline opacity
244
+ // "" — resolved, and the element has NO authored inline opacity
245
+ // null — unknown (no authored HTML supplied, element not found in it,
246
+ // and no runtime parse-time stamp)
247
+ // The just-written file (`authoredHtml`) is the current truth; the runtime's
248
+ // parse-time stamp (data-hf-authored-opacity, installAuthoredOpacityCapture)
249
+ // covers elements the file lookup can't resolve. Parsed lazily, at most once.
250
+ let authoredDoc: Document | null | undefined;
251
+ const findAuthoredSource = (el: HTMLElement): Element | null => {
252
+ if (authoredDoc === undefined) {
253
+ try {
254
+ authoredDoc = authoredHtml
255
+ ? new DOMParser().parseFromString(authoredHtml, "text/html")
256
+ : null;
257
+ } catch {
258
+ authoredDoc = null;
259
+ }
260
+ }
261
+ if (!authoredDoc) return null;
262
+ const hfId = el.getAttribute("data-hf-id");
263
+ if (hfId) return authoredDoc.querySelector(`[data-hf-id="${hfId}"]`);
264
+ return el.id ? authoredDoc.getElementById(el.id) : null;
265
+ };
266
+ const readAuthoredOpacity = (el: HTMLElement): string | null => {
267
+ const source = findAuthoredSource(el);
268
+ if (source instanceof HTMLElement) return source.style.opacity;
269
+ return readStampedAuthoredOpacity(el);
270
+ };
271
+
230
272
  // fallow-ignore-next-line complexity
231
273
  const doReload = () => {
232
274
  const timelines = win.__timelines;
@@ -283,19 +325,39 @@ export function applySoftReload(
283
325
  // nukes the element's CSS base (position, width, height, etc.) from the
284
326
  // HTML `style=""` attribute. Save → clear → restore → strip `transform`.
285
327
  if (allTargets.length > 0 && win.gsap?.set) {
286
- const saved: Array<[Element, string]> = [];
328
+ const saved: Array<[HTMLElement, string]> = [];
287
329
  for (const el of allTargets) {
288
- const s = (el as HTMLElement).style;
289
- if (s?.cssText != null) saved.push([el, s.cssText]);
330
+ // Iframe-realm node: instanceof HTMLElement fails across realms, and
331
+ // gsap targets() only yields elements here — style access is duck-typed.
332
+ const styled = el as HTMLElement;
333
+ if (styled.style?.cssText != null) saved.push([styled, styled.style.cssText]);
290
334
  }
291
335
  try {
292
336
  win.gsap.set(allTargets, { clearProps: "all" });
293
337
  } catch {}
294
338
  for (const [el, css] of saved) {
295
- const s = (el as HTMLElement).style;
296
- if (!s) continue;
339
+ const s = el.style;
297
340
  s.cssText = css;
298
341
  s.removeProperty("transform");
342
+ // The restored cssText carries RUNTIME opacity, not authored opacity:
343
+ // a mid-flight tween's interpolated value, or the color-grading hide
344
+ // (`opacity: 0 !important`). The re-run script's tweens re-initialize
345
+ // against it — a from() captures it as its END, a to() as its START —
346
+ // turning the transient into the tween's permanent bound (dimmed or
347
+ // invisible elements). Put the AUTHORED inline opacity back; the seek
348
+ // below re-renders the correct animated value either way.
349
+ const authored = readAuthoredOpacity(el);
350
+ if (authored !== null) {
351
+ applyAuthoredInlineOpacity(s, authored);
352
+ } else if (
353
+ el.hasAttribute(COLOR_GRADING_SOURCE_HIDDEN_ATTR) &&
354
+ s.getPropertyValue("opacity") === "0" &&
355
+ s.getPropertyPriority("opacity") === "important"
356
+ ) {
357
+ // Authored value unknown, but this is definitely the grading hide —
358
+ // never let a from() capture 0; fall back to the CSS cascade.
359
+ s.removeProperty("opacity");
360
+ }
299
361
  }
300
362
  }
301
363