@hyperframes/studio 0.7.57 → 0.7.58

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 (112) hide show
  1. package/dist/assets/{hyperframes-player--Z69cEkE.js → hyperframes-player-CtTDO63S.js} +1 -1
  2. package/dist/assets/{index-DfmYkU44.js → index-B_UvTX3E.js} +204 -204
  3. package/dist/assets/{index-KsfE1bUu.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-Bf1x1y8H.js → index-DeQPzqwH.js} +1 -1
  5. package/dist/assets/index-uahwWkgw.css +1 -0
  6. package/dist/{chunk-5QSIMBEJ.js → chunk-OBAG3GWK.js} +33 -21
  7. package/dist/chunk-OBAG3GWK.js.map +1 -0
  8. package/dist/{domEditingLayers-6LQGKPOI.js → domEditingLayers-2CKWGEHS.js} +2 -2
  9. package/dist/index.d.ts +15 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +5954 -4474
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +3 -3
  15. package/src/components/StudioFeedbackBar.tsx +2 -2
  16. package/src/components/editor/CanvasContextMenu.test.tsx +27 -14
  17. package/src/components/editor/CanvasContextMenu.tsx +71 -6
  18. package/src/components/editor/DomEditOverlay.tsx +5 -2
  19. package/src/components/editor/LayersPanel.tsx +110 -99
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +57 -0
  21. package/src/components/editor/canvasContextMenuZOrder.ts +40 -1
  22. package/src/components/editor/domEditingDom.ts +5 -6
  23. package/src/components/editor/domEditingElement.ts +15 -32
  24. package/src/components/editor/layersPanelSort.ts +80 -0
  25. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  26. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  27. package/src/components/nle/PreviewOverlays.tsx +27 -2
  28. package/src/components/nle/TimelinePane.test.ts +3 -0
  29. package/src/components/nle/TimelinePane.tsx +11 -1
  30. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  31. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  32. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  33. package/src/components/nle/zLaneGesture.test.ts +176 -0
  34. package/src/components/nle/zLaneGesture.ts +77 -0
  35. package/src/hooks/domEditCommitTypes.ts +17 -1
  36. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  37. package/src/hooks/fetchStubTestUtils.ts +16 -0
  38. package/src/hooks/timelineEditingHelpers.test.ts +92 -1
  39. package/src/hooks/timelineEditingHelpers.ts +32 -15
  40. package/src/hooks/timelineMoveAdapter.ts +2 -1
  41. package/src/hooks/timelineTimingSync.test.ts +703 -33
  42. package/src/hooks/timelineTimingSync.ts +282 -97
  43. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  44. package/src/hooks/timelineTrackVisibility.ts +4 -3
  45. package/src/hooks/useClipboard.ts +19 -17
  46. package/src/hooks/useDomEditCommits.test.tsx +316 -38
  47. package/src/hooks/useDomEditCommits.ts +143 -78
  48. package/src/hooks/useDomEditSession.test.tsx +3 -1
  49. package/src/hooks/useDomEditSession.ts +1 -1
  50. package/src/hooks/useElementLifecycleOps.test.tsx +426 -60
  51. package/src/hooks/useElementLifecycleOps.ts +174 -81
  52. package/src/hooks/usePreviewPersistence.ts +2 -2
  53. package/src/hooks/useTimelineEditing.test.tsx +302 -154
  54. package/src/hooks/useTimelineEditing.ts +97 -93
  55. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  56. package/src/hooks/useTimelineGroupEditing.ts +35 -3
  57. package/src/player/components/Timeline.test.ts +31 -21
  58. package/src/player/components/Timeline.tsx +62 -91
  59. package/src/player/components/TimelineCanvas.tsx +36 -5
  60. package/src/player/components/TimelineLanes.tsx +32 -10
  61. package/src/player/components/TimelineOverlays.tsx +36 -0
  62. package/src/player/components/TimelineRuler.tsx +18 -5
  63. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  64. package/src/player/components/timelineCallbacks.ts +4 -1
  65. package/src/player/components/timelineClipDragCommit.test.ts +279 -3
  66. package/src/player/components/timelineClipDragCommit.ts +173 -71
  67. package/src/player/components/timelineGapCommit.test.ts +163 -0
  68. package/src/player/components/timelineGapCommit.ts +103 -0
  69. package/src/player/components/timelineGaps.test.ts +221 -0
  70. package/src/player/components/timelineGaps.ts +182 -0
  71. package/src/player/components/timelineLayout.test.ts +2 -1
  72. package/src/player/components/timelineLayout.ts +22 -6
  73. package/src/player/components/timelineMarquee.test.ts +20 -10
  74. package/src/player/components/timelineMarquee.ts +9 -2
  75. package/src/player/components/timelineStackingSync.test.ts +42 -1
  76. package/src/player/components/timelineStackingSync.ts +31 -16
  77. package/src/player/components/timelineZMirror.test.ts +667 -0
  78. package/src/player/components/timelineZMirror.ts +393 -0
  79. package/src/player/components/timelineZoom.test.ts +2 -2
  80. package/src/player/components/timelineZoom.ts +7 -3
  81. package/src/player/components/useTimelineClipDrag.ts +1 -1
  82. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  83. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  84. package/src/player/components/useTimelinePlayhead.ts +4 -3
  85. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  86. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  87. package/src/player/components/useTimelineStackingSync.test.tsx +0 -1
  88. package/src/player/components/useTimelineStackingSync.ts +5 -3
  89. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  90. package/src/player/components/useTrackGapMenu.ts +155 -0
  91. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  92. package/src/player/hooks/useExpandedTimelineElements.ts +12 -1
  93. package/src/player/hooks/useTimelineSyncCallbacks.ts +2 -0
  94. package/src/player/lib/layerOrdering.test.ts +1 -15
  95. package/src/player/lib/layerOrdering.ts +5 -28
  96. package/src/player/lib/timelineDOM.test.ts +72 -0
  97. package/src/player/lib/timelineDOM.ts +3 -0
  98. package/src/player/lib/timelineElementHelpers.ts +35 -11
  99. package/src/player/store/playerStore.ts +12 -1
  100. package/src/telemetry/events.test.ts +10 -0
  101. package/src/telemetry/events.ts +1 -0
  102. package/src/utils/domEditSaveQueue.ts +4 -3
  103. package/src/utils/editHistory.test.ts +82 -0
  104. package/src/utils/editHistory.ts +14 -2
  105. package/src/utils/gsapSoftReload.test.ts +54 -115
  106. package/src/utils/gsapSoftReload.ts +42 -152
  107. package/src/utils/gsapUndoRestore.test.ts +269 -0
  108. package/src/utils/gsapUndoRestore.ts +269 -0
  109. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  110. package/dist/assets/index-_pqzyxB1.css +0 -1
  111. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  112. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { TimelineElement } from "../store/playerStore";
12
12
  import type { ClipManifestClip } from "./playbackTypes";
13
+ import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
13
14
  import {
14
15
  resolveMediaElement,
15
16
  applyMediaMetadataFromElement,
@@ -228,6 +229,7 @@ export function createImplicitTimelineLayersFromDOM(
228
229
  selector,
229
230
  selectorIndex,
230
231
  sourceFile,
232
+ stackingContextId: resolveCssStackingContextId(child),
231
233
  start: 0,
232
234
  tag: child.tagName.toLowerCase(),
233
235
  timingSource: "implicit",
@@ -303,6 +305,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
303
305
  selector,
304
306
  selectorIndex,
305
307
  sourceFile,
308
+ stackingContextId: resolveCssStackingContextId(el),
306
309
  timingSource: "authored",
307
310
  zIndex: readTimelineElementZIndex(el),
308
311
  };
@@ -10,6 +10,31 @@
10
10
  import type { TimelineElement } from "../store/playerStore";
11
11
  import type { ClipManifestClip } from "./playbackTypes";
12
12
  import { isFinitePositive } from "./playbackAdapter";
13
+ import { getSourceScopedSelectorIndex } from "../../utils/sourceScopedSelectorIndex";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Layer-reveal lift transparency
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Attributes carrying the pre-lift state of a Layers-panel selection reveal
21
+ * (useLayerRevealOverride): while a layer is selected it PAINTS on top via a
22
+ * temporary inline z-index, but the lift is a purely visual, ephemeral studio
23
+ * affordance — every z reader must keep reporting the element's TRUE z
24
+ * (stored here) so menus, badges, the lane mirror, and the panel sort never
25
+ * reason on the lifted value. A z-reorder commit removes the attributes (the
26
+ * commit is the new truth).
27
+ */
28
+ export const LAYER_REVEAL_PRIOR_Z_ATTR = "data-hf-reveal-prior-z";
29
+ export const LAYER_REVEAL_PRIOR_POSITION_ATTR = "data-hf-reveal-prior-pos";
30
+
31
+ /** The lifted element's true (pre-lift) z, or null when no lift is active. */
32
+ export function readLayerRevealPriorZ(el: Element): number | null {
33
+ const raw = el.getAttribute(LAYER_REVEAL_PRIOR_Z_ATTR);
34
+ if (raw == null) return null;
35
+ const n = Number.parseInt(raw, 10);
36
+ return Number.isFinite(n) ? n : null;
37
+ }
13
38
 
14
39
  // ---------------------------------------------------------------------------
15
40
  // Duration attribute helpers
@@ -22,8 +47,11 @@ import { isFinitePositive } from "./playbackAdapter";
22
47
  * back to computed style; "auto" / empty / unparseable ⇒ 0. Works with a
23
48
  * detached parse Document (no defaultView) as well as a live iframe. Mirrors
24
49
  * canvasContextMenuZOrder.parseZIndex semantics so the two directions agree.
50
+ * Reveal-lift transparent: an active lift reports the stored TRUE z.
25
51
  */
26
52
  export function readTimelineElementZIndex(el: Element): number {
53
+ const prior = readLayerRevealPriorZ(el);
54
+ if (prior != null) return prior;
27
55
  const html = el as HTMLElement;
28
56
  const parseZ = (value: string | null | undefined): number | null => {
29
57
  if (value == null || value === "" || value === "auto") return null;
@@ -237,17 +265,13 @@ export function getTimelineElementSelectorIndex(
237
265
  el: Element,
238
266
  selector: string | undefined,
239
267
  ): number | undefined {
240
- if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
241
- return undefined;
242
- }
243
-
244
- try {
245
- const matches = Array.from(doc.querySelectorAll(selector));
246
- const matchIndex = matches.indexOf(el);
247
- return matchIndex >= 0 ? matchIndex : undefined;
248
- } catch {
249
- return undefined;
250
- }
268
+ return getSourceScopedSelectorIndex(
269
+ doc,
270
+ el,
271
+ selector,
272
+ getTimelineElementSourceFile(el),
273
+ getTimelineElementSourceFile,
274
+ );
251
275
  }
252
276
 
253
277
  export function buildTimelineElementKey(params: {
@@ -44,7 +44,7 @@ export interface TimelineElement {
44
44
  zIndex?: number;
45
45
  /** True when the effective z-index was authored inline or through CSS, not auto. */
46
46
  hasExplicitZIndex?: boolean;
47
- /** Stacking context this element belongs to; root clips use the root composition id. */
47
+ /** Canonical CSS stacking context this element's z-index participates in. */
48
48
  stackingContextId?: string | null;
49
49
  /** Nearest parent composition context, matching RuntimeTimelineClip. */
50
50
  parentCompositionId?: string | null;
@@ -124,6 +124,13 @@ interface PlayerState {
124
124
  zoomMode: ZoomMode;
125
125
  /** Timeline zoom percent relative to the fit width when in manual mode */
126
126
  manualZoomPercent: number;
127
+ /**
128
+ * Bumped on every live z-index edit (handleDomZIndexReorderCommit apply AND
129
+ * rollback). Flashless z commits (skipReload) never reload the iframe or
130
+ * bump refreshKey, so DOM-derived views (the Layers panel's z-sorted tree)
131
+ * subscribe to this to re-read the live DOM while playback is paused.
132
+ */
133
+ zEditVersion: number;
127
134
  /** Work-area in-point (seconds). When set, loop starts here and A jumps here. */
128
135
  inPoint: number | null;
129
136
  /** Work-area out-point (seconds). When set, loop ends here and E jumps here. */
@@ -212,6 +219,7 @@ interface PlayerState {
212
219
  ) => void;
213
220
  setZoomMode: (mode: ZoomMode) => void;
214
221
  setManualZoomPercent: (percent: number) => void;
222
+ bumpZEditVersion: () => void;
215
223
  setInPoint: (time: number | null) => void;
216
224
  setOutPoint: (time: number | null) => void;
217
225
  reset: () => void;
@@ -274,6 +282,7 @@ export interface DomClipChild {
274
282
  /** The manifest sub-comp host clip id this descendant ultimately lives under. */
275
283
  hostId: string;
276
284
  label: string;
285
+ stackingContextId: string;
277
286
  }
278
287
 
279
288
  interface BeatHistoryEntry {
@@ -308,6 +317,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
308
317
  loopEnabled: false,
309
318
  zoomMode: "fit",
310
319
  manualZoomPercent: 100,
320
+ zEditVersion: 0,
311
321
  timelinePps: 100,
312
322
  timelineFitPps: 100,
313
323
  inPoint: null,
@@ -494,6 +504,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
494
504
  }),
495
505
  setManualZoomPercent: (percent) =>
496
506
  set({ manualZoomPercent: Math.max(10, Math.min(2000, Math.round(percent))) }),
507
+ bumpZEditVersion: () => set((state) => ({ zEditVersion: state.zEditVersion + 1 })),
497
508
  setCurrentTime: (time) => set({ currentTime: Number.isFinite(time) ? time : 0 }),
498
509
  setDuration: (duration) => set({ duration: Number.isFinite(duration) ? duration : 0 }),
499
510
  setTimelineReady: (ready) => set({ timelineReady: ready }),
@@ -12,6 +12,7 @@ const {
12
12
  trackStudioRenderStart,
13
13
  trackStudioRazorSplit,
14
14
  trackStudioExpandedClipEdit,
15
+ trackStudioFeedback,
15
16
  } = await import("./events");
16
17
 
17
18
  describe("studio telemetry events", () => {
@@ -69,4 +70,13 @@ describe("studio telemetry events", () => {
69
70
  trackStudioExpandedClipEdit({ action: "resize" });
70
71
  expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
71
72
  });
73
+
74
+ it.each([0, 10])("trackStudioFeedback preserves NPS boundary %i and its scale", (rating) => {
75
+ trackStudioFeedback({ rating });
76
+
77
+ expect(trackEvent).toHaveBeenCalledWith(
78
+ "survey sent",
79
+ expect.objectContaining({ $survey_response: rating, rating_scale: 10 }),
80
+ );
81
+ });
72
82
  });
@@ -67,6 +67,7 @@ export function trackStudioFeedback(props: { rating: number; comment?: string })
67
67
  trackEvent("survey sent", {
68
68
  $survey_id: "studio_experience",
69
69
  $survey_response: props.rating,
70
+ rating_scale: 10,
70
71
  ...(props.comment ? { $survey_response_2: props.comment } : {}),
71
72
  doctor_summary: getBrowserDoctorSummary(),
72
73
  source: "studio",
@@ -13,7 +13,7 @@ interface DomEditSaveQueueOptions {
13
13
  }
14
14
 
15
15
  export interface DomEditSaveQueue {
16
- enqueue: (save: () => Promise<void>) => Promise<void>;
16
+ enqueue: <T>(save: () => Promise<T>) => Promise<T>;
17
17
  waitForIdle: () => Promise<void>;
18
18
  reset: () => void;
19
19
  destroy: () => void;
@@ -52,10 +52,11 @@ export function createDomEditSaveQueue(options: DomEditSaveQueueOptions = {}): D
52
52
  });
53
53
  };
54
54
 
55
- const run = async (save: () => Promise<void>) => {
55
+ const run = async <T>(save: () => Promise<T>): Promise<T> => {
56
56
  try {
57
- await save();
57
+ const result = await save();
58
58
  if (!breakerOpen) consecutiveFailures = 0;
59
+ return result;
59
60
  } catch (error) {
60
61
  consecutiveFailures += 1;
61
62
  if (consecutiveFailures >= failureThreshold) open(error);
@@ -277,6 +277,88 @@ describe("edit history", () => {
277
277
  expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
278
278
  });
279
279
 
280
+ it("keeps timing-only files when a multi-file GSAP follow-up touches one path", () => {
281
+ const timing = buildEditHistoryEntry({
282
+ projectId: "project-1",
283
+ label: "Move timeline clips",
284
+ kind: "timeline",
285
+ coalesceKey: "timeline-group-move:a,b",
286
+ files: {
287
+ "index.html": { before: "index-before", after: "index-timing" },
288
+ "scene.html": { before: "scene-before", after: "scene-timing" },
289
+ },
290
+ now: 0,
291
+ id: "timing",
292
+ });
293
+ const gsap = buildEditHistoryEntry({
294
+ projectId: "project-1",
295
+ label: "Move timeline clips",
296
+ kind: "timeline",
297
+ coalesceKey: "timeline-group-move:a,b",
298
+ coalesceMs: 10_000,
299
+ files: {
300
+ "index.html": { before: "index-timing", after: "index-timing+gsap" },
301
+ },
302
+ now: 2000,
303
+ id: "gsap",
304
+ });
305
+
306
+ const state = pushEditHistoryEntry(
307
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
308
+ gsap,
309
+ );
310
+
311
+ expect(state.undo).toHaveLength(1);
312
+ expect(Object.keys(state.undo[0].files)).toEqual(["index.html", "scene.html"]);
313
+ expect(state.undo[0].files).toMatchObject({
314
+ "index.html": { before: "index-before", after: "index-timing+gsap" },
315
+ "scene.html": { before: "scene-before", after: "scene-timing" },
316
+ });
317
+ });
318
+
319
+ it("keeps a discontinuous GSAP follow-up separate and preserves foreign bytes on undo", () => {
320
+ const timing = buildEditHistoryEntry({
321
+ projectId: "project-1",
322
+ label: "Move timeline clip",
323
+ kind: "timeline",
324
+ coalesceKey: "timeline-move:clip",
325
+ files: { "index.html": { before: "A", after: "B" } },
326
+ now: 0,
327
+ id: "timing",
328
+ });
329
+ const gsapAfterForeignWrite = buildEditHistoryEntry({
330
+ projectId: "project-1",
331
+ label: "Move timeline clip",
332
+ kind: "timeline",
333
+ coalesceKey: "timeline-move:clip",
334
+ coalesceMs: 10_000,
335
+ files: { "index.html": { before: "F", after: "G" } },
336
+ now: 2000,
337
+ id: "gsap",
338
+ });
339
+
340
+ const state = pushEditHistoryEntry(
341
+ pushEditHistoryEntry(createEmptyEditHistory(), timing),
342
+ gsapAfterForeignWrite,
343
+ );
344
+
345
+ expect(state.undo.map((entry) => entry.id)).toEqual(["timing", "gsap"]);
346
+ const undoGsap = undoEditHistory(state, { "index.html": hashEditHistoryContent("G") }, 3000);
347
+ expect(undoGsap.ok).toBe(true);
348
+ expect(undoGsap.filesToWrite).toEqual({ "index.html": "F" });
349
+
350
+ const undoTiming = undoEditHistory(
351
+ undoGsap.state,
352
+ { "index.html": hashEditHistoryContent("F") },
353
+ 4000,
354
+ );
355
+ expect(undoTiming).toMatchObject({
356
+ ok: false,
357
+ reason: "content-mismatch",
358
+ filesToWrite: {},
359
+ });
360
+ });
361
+
280
362
  it("does not merge a slow follow-up without the coalesceMs override", () => {
281
363
  const timing = buildEditHistoryEntry({
282
364
  projectId: "project-1",
@@ -126,9 +126,21 @@ export function pushEditHistoryEntry(
126
126
  previous &&
127
127
  previous.coalesceKey &&
128
128
  previous.coalesceKey === entry.coalesceKey &&
129
- entry.createdAt - previous.createdAt <= coalesceMs
129
+ entry.createdAt - previous.createdAt <= coalesceMs &&
130
+ // A shared key/window is not enough: bytes from an external writer may
131
+ // have landed between the two Studio-owned edits. Only fold a path when
132
+ // the follow-up starts from the exact output the previous entry recorded.
133
+ // Otherwise keep both entries so undo restores the foreign bytes first and
134
+ // the older entry's content check safely blocks at that ownership boundary.
135
+ Object.entries(entry.files).every(([path, snapshot]) => {
136
+ const previousSnapshot = previous.files[path];
137
+ return !previousSnapshot || previousSnapshot.after === snapshot.before;
138
+ })
130
139
  ) {
131
- const files: Record<string, EditHistoryFileSnapshot> = {};
140
+ // A follow-up may touch only a subset of the files in the original edit
141
+ // (for example, only one file in a multi-file timing move has GSAP). Keep
142
+ // previous-only paths in the single coalesced undo entry.
143
+ const files: Record<string, EditHistoryFileSnapshot> = { ...previous.files };
132
144
  for (const [path, snapshot] of Object.entries(entry.files)) {
133
145
  const previousSnapshot = previous.files[path];
134
146
  files[path] = previousSnapshot
@@ -3,9 +3,8 @@
3
3
  import { describe, it, expect, vi } from "vitest";
4
4
  import {
5
5
  applySoftReload,
6
+ applySoftReloadFinalization,
6
7
  ensureMotionPathPluginLoaded,
7
- diffSoftReloadableRestore,
8
- applyUndoRestoreToPreview,
9
8
  } from "./gsapSoftReload";
10
9
 
11
10
  const SCRIPT_TEXT = `
@@ -66,6 +65,7 @@ function buildMockIframe(overrides: Record<string, unknown> = {}) {
66
65
  iframe: { contentWindow, contentDocument } as unknown as HTMLIFrameElement,
67
66
  contentWindow,
68
67
  mockTimeline,
68
+ container,
69
69
  };
70
70
  }
71
71
 
@@ -288,6 +288,58 @@ describe("applySoftReload", () => {
288
288
  });
289
289
  });
290
290
 
291
+ // ── Finalization-only path: seek → rebind → manual edits, with NO script
292
+ // execution — the flashless sync for timing edits that changed no script.
293
+ describe("applySoftReloadFinalization", () => {
294
+ it("seeks, rebinds, and reapplies manual edits without touching any script", () => {
295
+ const { iframe, contentWindow, container, mockTimeline } = buildMockIframe();
296
+ const scriptsBefore = container.querySelectorAll("script").length;
297
+
298
+ expect(applySoftReloadFinalization(iframe, 2.0)).toBe(true);
299
+
300
+ expect(contentWindow.__player.seek).toHaveBeenCalledWith(2.0);
301
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
302
+ expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalledTimes(1);
303
+ // No script executed or removed; the live timeline was never killed.
304
+ expect(container.querySelectorAll("script").length).toBe(scriptsBefore);
305
+ expect(mockTimeline.kill).not.toHaveBeenCalled();
306
+ expect(contentWindow.__timelines.root).toBe(mockTimeline);
307
+ });
308
+
309
+ it("runs inside __hfSuppressSceneMutations when the runtime provides it", () => {
310
+ const suppress = vi.fn(<T>(fn: () => T): T => fn());
311
+ const { iframe, contentWindow } = buildMockIframe({
312
+ __hfSuppressSceneMutations: suppress,
313
+ });
314
+
315
+ expect(applySoftReloadFinalization(iframe, 1.5)).toBe(true);
316
+
317
+ expect(suppress).toHaveBeenCalledTimes(1);
318
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
319
+ });
320
+
321
+ it("does NOT require gsap — a script-less runtime with the rebind hook works", () => {
322
+ const { iframe, contentWindow } = buildMockIframe({ gsap: undefined });
323
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(true);
324
+ expect(contentWindow.__hfForceTimelineRebind).toHaveBeenCalledTimes(1);
325
+ });
326
+
327
+ it("returns false when the iframe or the rebind hook is unavailable", () => {
328
+ expect(applySoftReloadFinalization(null, 0)).toBe(false);
329
+ const { iframe } = buildMockIframe({ __hfForceTimelineRebind: undefined });
330
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
331
+ });
332
+
333
+ it("returns false when the rebind throws (caller full-reloads)", () => {
334
+ const { iframe } = buildMockIframe({
335
+ __hfForceTimelineRebind: vi.fn(() => {
336
+ throw new Error("runtime mid-teardown");
337
+ }),
338
+ });
339
+ expect(applySoftReloadFinalization(iframe, 0)).toBe(false);
340
+ });
341
+ });
342
+
291
343
  function buildBootstrapIframe(overrides: Record<string, unknown> = {}) {
292
344
  const head = document.createElement("div");
293
345
  const appendedScripts: HTMLScriptElement[] = [];
@@ -452,116 +504,3 @@ describe("applySoftReload authored-opacity restore", () => {
452
504
  expect(restoreOpacity(el)).toBe("");
453
505
  });
454
506
  });
455
-
456
- // ── Bug 2: undo/redo restore soft-apply ──────────────────────────────────────
457
-
458
- const wrap = (body: string) => `<html><body>${body}</body></html>`;
459
-
460
- describe("diffSoftReloadableRestore", () => {
461
- it("reports the changed id for an attribute/inline-style-only diff", () => {
462
- const prev = wrap(`<div id="a" style="translate: 10px 10px">t</div>`);
463
- const next = wrap(`<div id="a" style="translate: 0px 0px">t</div>`);
464
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: ["a"] });
465
- });
466
-
467
- it("treats a structural change (added element) as NOT soft-reloadable", () => {
468
- const prev = wrap(`<div id="a">t</div>`);
469
- const next = wrap(`<div id="a">t</div><div id="a-split">t</div>`);
470
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
471
- });
472
-
473
- it("treats an element text/child change as NOT soft-reloadable", () => {
474
- const prev = wrap(`<div id="a">one</div>`);
475
- const next = wrap(`<div id="a">two</div>`);
476
- expect(diffSoftReloadableRestore(prev, next)).toBeNull();
477
- });
478
-
479
- it("allows a GSAP-script-only change (no id'd-attribute diff)", () => {
480
- const prev = wrap(
481
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:1});</script>`,
482
- );
483
- const next = wrap(
484
- `<div id="a">t</div><script>window.__timelines["root"]=gsap.timeline().to("#a",{x:9});</script>`,
485
- );
486
- expect(diffSoftReloadableRestore(prev, next)).toEqual({ changedElementIds: [] });
487
- });
488
- });
489
-
490
- function buildLiveIframe(bodyHtml: string) {
491
- const doc = document.implementation.createHTMLDocument("");
492
- doc.body.innerHTML = bodyHtml;
493
- const contentWindow = {
494
- gsap: { timeline: () => {} },
495
- __hfForceTimelineRebind: () => {},
496
- __timelines: {} as Record<string, unknown>,
497
- __player: { getTime: () => 3, seek: vi.fn() },
498
- __hfStudioManualEditsApply: vi.fn(),
499
- };
500
- return {
501
- iframe: { contentWindow, contentDocument: doc } as unknown as HTMLIFrameElement,
502
- contentWindow,
503
- doc,
504
- };
505
- }
506
-
507
- describe("applyUndoRestoreToPreview", () => {
508
- const ROOT = "index.html";
509
-
510
- it("soft-applies an attribute/style-only restore: syncs the live element, no full reload", () => {
511
- const { iframe, contentWindow, doc } = buildLiveIframe(
512
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
513
- );
514
- const reloadPreview = vi.fn();
515
- const files = {
516
- [ROOT]: {
517
- previous: wrap(
518
- `<div id="a" style="translate: 10px 10px" data-hf-path-offset="true">t</div>`,
519
- ),
520
- restored: wrap(`<div id="a" style="translate: 0px 0px" data-hf-path-offset="true">t</div>`),
521
- },
522
- };
523
- const outcome = applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview);
524
- expect(outcome).toBe("soft");
525
- expect(reloadPreview).not.toHaveBeenCalled();
526
- // Live element reverted to the restored inline style.
527
- expect(doc.getElementById("a")!.getAttribute("style")).toBe("translate: 0px 0px");
528
- // No GSAP script in the restore → the manual-edit reapply runs, playhead held.
529
- expect(contentWindow.__player.seek).toHaveBeenCalledWith(3);
530
- expect(contentWindow.__hfStudioManualEditsApply).toHaveBeenCalled();
531
- });
532
-
533
- it("full-reloads a multi-file restore", () => {
534
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
535
- const reloadPreview = vi.fn();
536
- const files = {
537
- [ROOT]: {
538
- previous: wrap(`<div id="a" style="x">t</div>`),
539
- restored: wrap(`<div id="a">t</div>`),
540
- },
541
- "scenes/intro.html": { previous: "a", restored: "b" },
542
- };
543
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
544
- expect(reloadPreview).toHaveBeenCalledTimes(1);
545
- });
546
-
547
- it("full-reloads a structural restore (split/delete undo)", () => {
548
- const { iframe } = buildLiveIframe(`<div id="a">t</div><div id="a-split">t</div>`);
549
- const reloadPreview = vi.fn();
550
- const files = {
551
- [ROOT]: {
552
- previous: wrap(`<div id="a">t</div><div id="a-split">t</div>`),
553
- restored: wrap(`<div id="a">t</div>`),
554
- },
555
- };
556
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
557
- expect(reloadPreview).toHaveBeenCalledTimes(1);
558
- });
559
-
560
- it("full-reloads when the restore touches a sub-comp, not the active comp", () => {
561
- const { iframe } = buildLiveIframe(`<div id="a">t</div>`);
562
- const reloadPreview = vi.fn();
563
- const files = { "scenes/intro.html": { previous: "a", restored: "b" } };
564
- expect(applyUndoRestoreToPreview(iframe, ROOT, files, 3, reloadPreview)).toBe("full");
565
- expect(reloadPreview).toHaveBeenCalledTimes(1);
566
- });
567
- });