@hyperframes/studio 0.7.56 → 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 (146) hide show
  1. package/dist/assets/hyperframes-player-CtTDO63S.js +459 -0
  2. package/dist/assets/{index-CmVCjZjp.js → index-B_UvTX3E.js} +205 -205
  3. package/dist/assets/{index-BWnOxAiH.js → index-C47jAC3Q.js} +1 -1
  4. package/dist/assets/{index-C4csZims.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 +41 -4
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +7436 -5423
  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 +166 -2
  17. package/src/components/editor/CanvasContextMenu.tsx +100 -20
  18. package/src/components/editor/DomEditOverlay.tsx +34 -62
  19. package/src/components/editor/LayersPanel.tsx +111 -91
  20. package/src/components/editor/canvasContextMenuZOrder.test.ts +243 -2
  21. package/src/components/editor/canvasContextMenuZOrder.ts +184 -23
  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/propertyPanelColorGradingSection.tsx +15 -21
  26. package/src/components/editor/useCanvasContextMenuState.ts +92 -0
  27. package/src/components/editor/useLayerRevealOverride.test.ts +169 -0
  28. package/src/components/editor/useLayerRevealOverride.ts +389 -0
  29. package/src/components/editor/useZOrderCrossedFlash.tsx +68 -0
  30. package/src/components/nle/AssetPreviewOverlay.test.tsx +106 -0
  31. package/src/components/nle/AssetPreviewOverlay.tsx +61 -37
  32. package/src/components/nle/PreviewOverlays.tsx +61 -16
  33. package/src/components/nle/TimelinePane.test.ts +3 -0
  34. package/src/components/nle/TimelinePane.tsx +11 -1
  35. package/src/components/nle/useCanvasZOrderTimelineMirror.test.tsx +440 -0
  36. package/src/components/nle/useCanvasZOrderTimelineMirror.ts +191 -0
  37. package/src/components/nle/useTimelineEditCallbacks.ts +1 -0
  38. package/src/components/nle/zLaneGesture.test.ts +176 -0
  39. package/src/components/nle/zLaneGesture.ts +77 -0
  40. package/src/components/sidebar/AssetCard.test.tsx +106 -0
  41. package/src/components/sidebar/AssetCard.tsx +19 -2
  42. package/src/components/sidebar/AudioRow.tsx +19 -2
  43. package/src/hooks/domEditCommitTypes.ts +29 -2
  44. package/src/hooks/elementLifecycleOpsTestUtils.ts +25 -0
  45. package/src/hooks/fetchStubTestUtils.ts +16 -0
  46. package/src/hooks/timelineEditingHelpers.test.ts +183 -1
  47. package/src/hooks/timelineEditingHelpers.ts +63 -259
  48. package/src/hooks/timelineMoveAdapter.test.ts +44 -40
  49. package/src/hooks/timelineMoveAdapter.ts +7 -4
  50. package/src/hooks/timelineTimingSync.test.ts +820 -0
  51. package/src/hooks/timelineTimingSync.ts +597 -0
  52. package/src/hooks/timelineTrackVisibility.test.ts +3 -1
  53. package/src/hooks/timelineTrackVisibility.ts +4 -3
  54. package/src/hooks/useCaptionDetection.ts +2 -0
  55. package/src/hooks/useClipboard.ts +19 -17
  56. package/src/hooks/useCompositionDimensions.ts +32 -5
  57. package/src/hooks/useDomEditCommits.test.tsx +392 -23
  58. package/src/hooks/useDomEditCommits.ts +180 -53
  59. package/src/hooks/useDomEditSession.test.tsx +3 -1
  60. package/src/hooks/useDomEditSession.ts +1 -1
  61. package/src/hooks/useElementLifecycleOps.test.tsx +559 -28
  62. package/src/hooks/useElementLifecycleOps.ts +175 -69
  63. package/src/hooks/useElementPicker.ts +7 -8
  64. package/src/hooks/usePreviewPersistence.ts +2 -2
  65. package/src/hooks/useTimelineAssetDropOps.ts +175 -0
  66. package/src/hooks/useTimelineEditing.test.tsx +591 -277
  67. package/src/hooks/useTimelineEditing.ts +202 -282
  68. package/src/hooks/useTimelineEditingTypes.ts +1 -1
  69. package/src/hooks/useTimelineGroupEditing.ts +187 -134
  70. package/src/player/components/PlayerControls.tsx +5 -5
  71. package/src/player/components/PlayheadIndicator.tsx +7 -2
  72. package/src/player/components/Timeline.test.ts +105 -18
  73. package/src/player/components/Timeline.tsx +69 -98
  74. package/src/player/components/TimelineCanvas.tsx +47 -7
  75. package/src/player/components/TimelineLanes.tsx +32 -10
  76. package/src/player/components/TimelineOverlays.tsx +36 -0
  77. package/src/player/components/TimelineRuler.tsx +70 -47
  78. package/src/player/components/TrackGapContextMenu.tsx +116 -0
  79. package/src/player/components/timelineCallbacks.ts +4 -1
  80. package/src/player/components/timelineClipDragCommit.test.ts +370 -11
  81. package/src/player/components/timelineClipDragCommit.ts +267 -68
  82. package/src/player/components/timelineGapCommit.test.ts +163 -0
  83. package/src/player/components/timelineGapCommit.ts +103 -0
  84. package/src/player/components/timelineGaps.test.ts +221 -0
  85. package/src/player/components/timelineGaps.ts +182 -0
  86. package/src/player/components/timelineLayout.test.ts +2 -1
  87. package/src/player/components/timelineLayout.ts +119 -30
  88. package/src/player/components/timelineMarquee.test.ts +20 -10
  89. package/src/player/components/timelineMarquee.ts +9 -2
  90. package/src/player/components/timelineRevealScroll.test.ts +133 -0
  91. package/src/player/components/timelineRevealScroll.ts +91 -0
  92. package/src/player/components/timelineStackingSync.test.ts +102 -1
  93. package/src/player/components/timelineStackingSync.ts +45 -5
  94. package/src/player/components/timelineTrackPersistPipeline.test.ts +168 -0
  95. package/src/player/components/timelineZMirror.test.ts +667 -0
  96. package/src/player/components/timelineZMirror.ts +393 -0
  97. package/src/player/components/timelineZones.test.ts +122 -310
  98. package/src/player/components/timelineZones.ts +82 -135
  99. package/src/player/components/timelineZoom.test.ts +2 -2
  100. package/src/player/components/timelineZoom.ts +7 -3
  101. package/src/player/components/useTimelineClipDrag.ts +1 -1
  102. package/src/player/components/useTimelineGapHighlights.test.ts +119 -0
  103. package/src/player/components/useTimelineGapHighlights.ts +110 -0
  104. package/src/player/components/useTimelinePlayhead.ts +4 -3
  105. package/src/player/components/useTimelineRangeSelection.ts +6 -4
  106. package/src/player/components/useTimelineRevealClip.ts +56 -0
  107. package/src/player/components/useTimelineScrollViewport.ts +73 -0
  108. package/src/player/components/useTimelineStackingSync.test.tsx +11 -2
  109. package/src/player/components/useTimelineStackingSync.ts +17 -4
  110. package/src/player/components/useTimelineTrackDerivations.ts +38 -0
  111. package/src/player/components/useTrackGapMenu.ts +155 -0
  112. package/src/player/hooks/useExpandedTimelineElements.test.ts +67 -4
  113. package/src/player/hooks/useExpandedTimelineElements.ts +19 -1
  114. package/src/player/hooks/useTimelinePlayer.seek.test.ts +55 -0
  115. package/src/player/hooks/useTimelinePlayer.test.ts +36 -3
  116. package/src/player/hooks/useTimelinePlayer.ts +4 -0
  117. package/src/player/hooks/useTimelineSyncCallbacks.ts +11 -1
  118. package/src/player/index.ts +3 -1
  119. package/src/player/lib/layerOrdering.test.ts +1 -15
  120. package/src/player/lib/layerOrdering.ts +5 -28
  121. package/src/player/lib/runtimeProtocol.test.ts +48 -0
  122. package/src/player/lib/runtimeProtocol.ts +65 -0
  123. package/src/player/lib/timelineDOM.test.ts +72 -0
  124. package/src/player/lib/timelineDOM.ts +12 -0
  125. package/src/player/lib/timelineElementHelpers.ts +54 -11
  126. package/src/player/lib/timelineIframeHelpers.ts +2 -4
  127. package/src/player/store/playerStore.test.ts +38 -13
  128. package/src/player/store/playerStore.ts +49 -1
  129. package/src/telemetry/events.test.ts +10 -0
  130. package/src/telemetry/events.ts +1 -0
  131. package/src/utils/assetPreviewDismiss.test.ts +27 -0
  132. package/src/utils/assetPreviewDismiss.ts +29 -0
  133. package/src/utils/domEditSaveQueue.ts +4 -3
  134. package/src/utils/editHistory.test.ts +82 -0
  135. package/src/utils/editHistory.ts +14 -2
  136. package/src/utils/gsapSoftReload.test.ts +54 -115
  137. package/src/utils/gsapSoftReload.ts +42 -152
  138. package/src/utils/gsapUndoRestore.test.ts +269 -0
  139. package/src/utils/gsapUndoRestore.ts +269 -0
  140. package/src/utils/sourceScopedSelectorIndex.ts +29 -0
  141. package/src/utils/timelineAssetDrop.test.ts +144 -52
  142. package/src/utils/timelineAssetDrop.ts +62 -86
  143. package/dist/assets/hyperframes-player-BBrKzTGd.js +0 -459
  144. package/dist/assets/index-D-GyYi2d.css +0 -1
  145. package/dist/chunk-5QSIMBEJ.js.map +0 -1
  146. /package/dist/{domEditingLayers-6LQGKPOI.js.map → domEditingLayers-2CKWGEHS.js.map} +0 -0
@@ -0,0 +1,667 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { TimelineElement } from "../store/playerStore";
3
+ import {
4
+ resolveRepositionLaneMove,
5
+ resolveZMirrorLaneMove,
6
+ type ZMirrorInput,
7
+ } from "./timelineZMirror";
8
+
9
+ function el(
10
+ id: string,
11
+ track: number,
12
+ start: number,
13
+ duration: number,
14
+ extra: Partial<TimelineElement> = {},
15
+ ): TimelineElement {
16
+ return { id, key: id, tag: "video", start, duration, track, domId: id, ...extra };
17
+ }
18
+
19
+ function audio(id: string, track: number, start: number, duration: number): TimelineElement {
20
+ return el(id, track, start, duration, { tag: "audio" });
21
+ }
22
+
23
+ function resolve(
24
+ action: ZMirrorInput["action"],
25
+ element: TimelineElement,
26
+ elements: TimelineElement[],
27
+ crossedKey?: string | null,
28
+ ) {
29
+ return resolveZMirrorLaneMove({ action, element, elements, crossedKey });
30
+ }
31
+
32
+ // Target on TOP lane 0; b/c fully occupy the two lanes below over t's span.
33
+ const stackBelow = () => {
34
+ const t = el("t", 0, 0, 10);
35
+ const b = el("b", 1, 0, 10);
36
+ const c = el("c", 2, 0, 10);
37
+ return { t, elements: [t, b, c] };
38
+ };
39
+
40
+ // Sparse file: authored tracks 3/5/7 displayed as lanes 0/1/2 (a free over t's span).
41
+ const sparseAuthored = () => {
42
+ const a = el("a", 0, 20, 5, { authoredTrack: 3 });
43
+ const b = el("b", 1, 0, 10, { authoredTrack: 5 });
44
+ const t = el("t", 2, 0, 10, { authoredTrack: 7 });
45
+ return { t, elements: [a, b, t] };
46
+ };
47
+
48
+ describe("resolveZMirrorLaneMove — bring-forward / send-backward", () => {
49
+ // Stack: a on lane 0, b on lane 1, target on lane 2 — all overlapping in time.
50
+ const stack = () => {
51
+ const a = el("a", 0, 0, 10);
52
+ const b = el("b", 1, 0, 10);
53
+ const t = el("t", 2, 0, 10);
54
+ return { a, b, t, elements: [a, b, t] };
55
+ };
56
+
57
+ it("bring-forward with crossedKey lands on the closest free lane above the neighbor", () => {
58
+ // Free lane 0 exists above the crossed neighbor's lane... make lane 0 free by
59
+ // shifting a out of the span.
60
+ const a = el("a", 0, 20, 5); // lane 0 free over t's span
61
+ const b = el("b", 1, 0, 10);
62
+ const t = el("t", 2, 0, 10);
63
+ expect(resolve("bring-forward", t, [a, b, t], "b")).toEqual({
64
+ kind: "move",
65
+ displayTrack: 0,
66
+ persistTrack: 0,
67
+ });
68
+ });
69
+
70
+ it("bring-forward with crossedKey inserts above the neighbor when no lane is free", () => {
71
+ const { t, elements } = stack();
72
+ // Lanes 0 and 1 both occupied over t's span → new lane at the boundary
73
+ // ABOVE the crossed neighbor (row of lane 1 in the ascending order).
74
+ expect(resolve("bring-forward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 1 });
75
+ });
76
+
77
+ it("bring-forward without crossedKey uses the closest overlapping neighbor above", () => {
78
+ const { t, elements } = stack();
79
+ // Closest overlapping neighbor above lane 2 is b (lane 1); lanes 0/1 are
80
+ // occupied → insert above b, same as the crossedKey case.
81
+ expect(resolve("bring-forward", t, elements)).toEqual({ kind: "insert", insertRow: 1 });
82
+ });
83
+
84
+ it("bring-forward with an unknown crossedKey falls back to the temporal neighbor", () => {
85
+ const { t, elements } = stack();
86
+ expect(resolve("bring-forward", t, elements, "nope")).toEqual({
87
+ kind: "insert",
88
+ insertRow: 1,
89
+ });
90
+ });
91
+
92
+ it("bring-forward returns null when nothing overlaps above and no crossedKey", () => {
93
+ const a = el("a", 0, 20, 5); // above but NOT overlapping in time
94
+ const t = el("t", 1, 0, 10);
95
+ expect(resolve("bring-forward", t, [a, t])).toBeNull();
96
+ });
97
+
98
+ it("send-backward lands on the closest free lane below the neighbor", () => {
99
+ const t = el("t", 0, 0, 10);
100
+ const b = el("b", 1, 0, 10);
101
+ const c = el("c", 2, 20, 5); // lane 2 free over t's span
102
+ expect(resolve("send-backward", t, [t, b, c], "b")).toEqual({
103
+ kind: "move",
104
+ displayTrack: 2,
105
+ persistTrack: 2,
106
+ });
107
+ });
108
+
109
+ it("send-backward inserts below the neighbor when no lane below is free", () => {
110
+ const { t, elements } = stackBelow();
111
+ // Boundary below b's lane (row 1 + 1 = 2).
112
+ expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
113
+ });
114
+
115
+ it("send-backward returns null when nothing overlaps below and no crossedKey", () => {
116
+ const t = el("t", 0, 0, 10);
117
+ const b = el("b", 1, 20, 5);
118
+ expect(resolve("send-backward", t, [t, b])).toBeNull();
119
+ });
120
+
121
+ it("BOUNDED: never steps past the next overlapping element to a farther free lane", () => {
122
+ // Above neighbor b (lane 2): lane 1 holds x — the NEXT temporally-overlapping
123
+ // same-file element in the direction — and lane 0 is free. A single forward
124
+ // step crosses ONE element, so the free lane 0 beyond x is out of reach:
125
+ // insert immediately above b instead (row of lane 2 in the ascending order).
126
+ const a = el("a", 0, 30, 5); // lane 0 free over t's span — but beyond the bound
127
+ const x = el("x", 1, 5, 10); // overlaps t → the exclusive bound
128
+ const b = el("b", 2, 0, 10);
129
+ const t = el("t", 3, 0, 10);
130
+ expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
131
+ kind: "insert",
132
+ insertRow: 2,
133
+ });
134
+ });
135
+
136
+ it("OPEN SPACE: with no second overlapping element, skips an occupied lane to the next free one", () => {
137
+ // Lane 1's occupant is a FOREIGN-file clip: it occupies the lane (freeness is
138
+ // file-agnostic) but is not in the same stacking context, so it does not
139
+ // bound the step — the search continues to free lane 0, as before.
140
+ const a = el("a", 0, 30, 5); // lane 0 free over t's span
141
+ const x = el("x", 1, 5, 10, { sourceFile: "sub.html" });
142
+ const b = el("b", 2, 0, 10);
143
+ const t = el("t", 3, 0, 10);
144
+ expect(resolve("bring-forward", t, [a, x, b, t], "b")).toEqual({
145
+ kind: "move",
146
+ displayTrack: 0,
147
+ persistTrack: 0,
148
+ });
149
+ });
150
+
151
+ it("returns null when the closest free lane is the clip's own lane (z/track divergence)", () => {
152
+ // Crossed neighbor sits BELOW the clip in lane space (diverged z): searching
153
+ // up from lane 2 finds lane 1 free — the clip's own lane → already in place.
154
+ const t = el("t", 1, 0, 10);
155
+ const b = el("b", 2, 0, 10);
156
+ expect(resolve("bring-forward", t, [t, b], "b")).toBeNull();
157
+ });
158
+ });
159
+
160
+ describe("resolveZMirrorLaneMove — one-element step bound (forward/backward)", () => {
161
+ // Three stacked back-to-back clips (lanes 0/1/2, all overlapping) plus a free
162
+ // lane BEYOND the far element — the lane the old resolver would overshoot to.
163
+ const threeStackedWithFarFree = () => {
164
+ const a = el("a", 0, 0, 10);
165
+ const b = el("b", 1, 0, 10);
166
+ const c = el("c", 2, 0, 10);
167
+ const d = el("d", 3, 20, 5); // lane 3 free over the span — beyond c
168
+ return { a, b, c, d, elements: [a, b, c, d] };
169
+ };
170
+
171
+ it("send-backward from the top inserts between elements 1 and 2 — not past element 2", () => {
172
+ const { a, elements } = threeStackedWithFarFree();
173
+ // Reference = b (lane 1); next overlap below = c (lane 2) bounds the search;
174
+ // no free lane strictly between → insert at the b/c boundary (row 2), NOT
175
+ // the farther free lane 3.
176
+ expect(resolve("send-backward", a, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
177
+ });
178
+
179
+ it("bring-forward from the bottom inserts between elements 1 and 2 (symmetric)", () => {
180
+ const d = el("d", 0, 20, 5); // lane 0 free over the span — beyond a
181
+ const a = el("a", 1, 0, 10);
182
+ const b = el("b", 2, 0, 10);
183
+ const t = el("t", 3, 0, 10);
184
+ // Reference = b (lane 2); next overlap above = a (lane 1) bounds the search;
185
+ // no free lane strictly between → insert at the a/b boundary (row 2), NOT
186
+ // the farther free lane 0.
187
+ expect(resolve("bring-forward", t, [d, a, b, t], "b")).toEqual({
188
+ kind: "insert",
189
+ insertRow: 2,
190
+ });
191
+ });
192
+
193
+ it("takes a free lane strictly between the reference and the next overlap", () => {
194
+ const a = el("a", 0, 0, 10); // second element — the exclusive bound
195
+ const gap = el("gap", 1, 20, 5); // lane 1 free over the span, inside the interval
196
+ const b = el("b", 2, 0, 10); // crossed reference
197
+ const t = el("t", 3, 0, 10);
198
+ expect(resolve("bring-forward", t, [a, gap, b, t], "b")).toEqual({
199
+ kind: "move",
200
+ displayTrack: 1,
201
+ persistTrack: 1,
202
+ });
203
+ });
204
+
205
+ it("of several free lanes in the interval, takes the one closest to the reference", () => {
206
+ const a = el("a", 0, 0, 10); // bound
207
+ const g1 = el("g1", 1, 20, 5); // free, farther from reference
208
+ const g2 = el("g2", 2, 20, 5); // free, closest to reference
209
+ const b = el("b", 3, 0, 10); // crossed reference
210
+ const t = el("t", 4, 0, 10);
211
+ expect(resolve("bring-forward", t, [a, g1, g2, b, t], "b")).toEqual({
212
+ kind: "move",
213
+ displayTrack: 2,
214
+ persistTrack: 2,
215
+ });
216
+ });
217
+
218
+ it("no second overlapping element beyond the reference → the zone edge bounds (as today)", () => {
219
+ const { t, elements } = stackBelow();
220
+ // Only c overlaps below the reference b... remove c's overlap: reference is
221
+ // then the ONLY overlap below; the search runs to the zone edge and takes
222
+ // the free lane beyond the neighbor.
223
+ const spread = elements.map((e) => (e.id === "c" ? { ...e, start: 20 } : e));
224
+ expect(resolve("send-backward", t, spread, "b")).toEqual({
225
+ kind: "move",
226
+ displayTrack: 2,
227
+ persistTrack: 2,
228
+ });
229
+ });
230
+
231
+ it("bring-to-front is NOT bounded: still moves past the whole overlap set", () => {
232
+ const { t, elements } = (() => {
233
+ const free = el("free", 0, 20, 5); // free lane beyond the topmost overlap
234
+ const a = el("a", 1, 0, 10);
235
+ const b = el("b", 2, 0, 10);
236
+ const t = el("t", 3, 0, 10);
237
+ return { t, elements: [free, a, b, t] };
238
+ })();
239
+ expect(resolve("bring-to-front", t, elements)).toEqual({
240
+ kind: "move",
241
+ displayTrack: 0,
242
+ persistTrack: 0,
243
+ });
244
+ });
245
+ });
246
+
247
+ describe("resolveZMirrorLaneMove — bring-to-front / send-to-back", () => {
248
+ it("bring-to-front moves above the topmost temporally-overlapping clip", () => {
249
+ const a = el("a", 0, 20, 5); // lane 0 free over t's span
250
+ const b = el("b", 1, 0, 10); // topmost overlap
251
+ const c = el("c", 2, 0, 10);
252
+ const t = el("t", 3, 0, 10);
253
+ expect(resolve("bring-to-front", t, [a, b, c, t])).toEqual({
254
+ kind: "move",
255
+ displayTrack: 0,
256
+ persistTrack: 0,
257
+ });
258
+ });
259
+
260
+ it("bring-to-front inserts above the topmost overlap when no lane is free", () => {
261
+ const b = el("b", 0, 0, 10);
262
+ const c = el("c", 1, 0, 10);
263
+ const t = el("t", 2, 0, 10);
264
+ expect(resolve("bring-to-front", t, [b, c, t])).toEqual({ kind: "insert", insertRow: 0 });
265
+ });
266
+
267
+ it("bring-to-front is null when already topmost among overlaps (temporal scope)", () => {
268
+ // A clip exists on a higher lane but does NOT overlap in time — with the
269
+ // default temporal-overlap scope the target is already at the front.
270
+ const a = el("a", 0, 20, 5);
271
+ const t = el("t", 1, 0, 10);
272
+ const c = el("c", 2, 0, 10);
273
+ expect(resolve("bring-to-front", t, [a, t, c])).toBeNull();
274
+ });
275
+
276
+ it("send-to-back moves below the bottommost temporally-overlapping clip", () => {
277
+ const t = el("t", 0, 0, 10);
278
+ const b = el("b", 1, 0, 10); // bottommost overlap
279
+ const c = el("c", 2, 20, 5); // lane 2 free over t's span
280
+ expect(resolve("send-to-back", t, [t, b, c])).toEqual({
281
+ kind: "move",
282
+ displayTrack: 2,
283
+ persistTrack: 2,
284
+ });
285
+ });
286
+
287
+ it("send-to-back inserts below the bottommost overlap when no lane is free", () => {
288
+ const { t, elements } = stackBelow();
289
+ expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 3 });
290
+ });
291
+
292
+ it("send-to-back is null when already bottommost among overlaps", () => {
293
+ const t = el("t", 1, 0, 10);
294
+ const a = el("a", 0, 0, 10);
295
+ expect(resolve("send-to-back", t, [a, t])).toBeNull();
296
+ });
297
+
298
+ it("returns null when nothing overlaps at all", () => {
299
+ const t = el("t", 0, 0, 10);
300
+ const a = el("a", 1, 20, 5);
301
+ for (const action of ["bring-to-front", "send-to-back"] as const) {
302
+ expect(resolve(action, t, [t, a])).toBeNull();
303
+ }
304
+ });
305
+ });
306
+
307
+ describe("resolveZMirrorLaneMove — span freeness", () => {
308
+ it("a lane free at the clip's start but occupied later in the span is NOT free", () => {
309
+ const t = el("t", 2, 0, 10);
310
+ const b = el("b", 1, 0, 10); // crossed neighbor
311
+ // Lane 0: nothing at t=0, but occupied over [6, 9) — inside t's span.
312
+ const late = el("late", 0, 6, 3);
313
+ expect(resolve("bring-forward", t, [late, b, t], "b")).toEqual({
314
+ kind: "insert",
315
+ insertRow: 1,
316
+ });
317
+ });
318
+
319
+ it("half-open spans: a clip starting exactly at the moved clip's end does not occupy", () => {
320
+ const t = el("t", 2, 0, 10);
321
+ const b = el("b", 1, 0, 10);
322
+ const adjacent = el("adj", 0, 10, 5); // [10, 15) touches [0, 10) but no overlap
323
+ expect(resolve("bring-forward", t, [adjacent, b, t], "b")).toEqual({
324
+ kind: "move",
325
+ displayTrack: 0,
326
+ persistTrack: 0,
327
+ });
328
+ });
329
+
330
+ it("freeness is file-agnostic: an other-file clip occupies the lane", () => {
331
+ const t = el("t", 2, 0, 10);
332
+ const b = el("b", 1, 0, 10);
333
+ const foreign = el("f", 0, 0, 10, { sourceFile: "sub.html" });
334
+ expect(resolve("bring-forward", t, [foreign, b, t], "b")).toEqual({
335
+ kind: "insert",
336
+ insertRow: 1,
337
+ });
338
+ });
339
+ });
340
+
341
+ describe("resolveZMirrorLaneMove — zone boundary (audio untouched)", () => {
342
+ // Visual lanes 0-1, audio lanes 2-3.
343
+ const zoned = () => {
344
+ const t = el("t", 0, 0, 10);
345
+ const b = el("b", 1, 0, 10);
346
+ const m = audio("music", 2, 0, 30);
347
+ const vo = audio("vo", 3, 0, 30);
348
+ return { t, b, m, vo, elements: [t, b, m, vo] };
349
+ };
350
+
351
+ it("send-backward never lands on an audio lane — inserts at the zone boundary", () => {
352
+ const { t, elements } = zoned();
353
+ // Lane 2 (audio) is out of bounds even though "below"; boundary row 2 sits
354
+ // between the bottom visual lane and the first audio lane — a visual insert.
355
+ expect(resolve("send-backward", t, elements, "b")).toEqual({ kind: "insert", insertRow: 2 });
356
+ });
357
+
358
+ it("send-to-back stops at the visual zone edge", () => {
359
+ const { t, elements } = zoned();
360
+ expect(resolve("send-to-back", t, elements)).toEqual({ kind: "insert", insertRow: 2 });
361
+ });
362
+
363
+ it("audio clips never mirror (returns null)", () => {
364
+ const { m, elements } = zoned();
365
+ for (const action of [
366
+ "bring-to-front",
367
+ "bring-forward",
368
+ "send-backward",
369
+ "send-to-back",
370
+ ] as const) {
371
+ expect(resolve(action, m, elements)).toBeNull();
372
+ }
373
+ });
374
+
375
+ it("audio clips do not count as overlap references for visual clips", () => {
376
+ // Only audio below the target → send-backward has no visual neighbor → null.
377
+ const t = el("t", 0, 0, 10);
378
+ const m = audio("music", 1, 0, 30);
379
+ expect(resolve("send-backward", t, [t, m])).toBeNull();
380
+ expect(resolve("send-to-back", t, [t, m])).toBeNull();
381
+ });
382
+ });
383
+
384
+ describe("resolveZMirrorLaneMove — authored (persist) space", () => {
385
+ it("persistTrack takes the target lane occupant's authoredTrack, not the display lane", () => {
386
+ // Occupant of the free-over-span target lane 0 (authored 3) anchors the persist value.
387
+ const { t, elements } = sparseAuthored();
388
+ expect(resolve("bring-forward", t, elements, "b")).toEqual({
389
+ kind: "move",
390
+ displayTrack: 0,
391
+ persistTrack: 3,
392
+ });
393
+ });
394
+
395
+ it("falls back to nearest-same-file lane offset when the target lane has no same-file occupant", () => {
396
+ // The moved clip is an expanded sub-comp child; the target lane's only
397
+ // occupant belongs to the host file, so the persist value offsets from the
398
+ // nearest same-file lane instead (authored 4 at lane 1 → lane 0 = 3).
399
+ const host = el("h", 0, 20, 5); // host-file clip on the target lane (not overlapping)
400
+ const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 4 });
401
+ const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 5 });
402
+ expect(resolve("bring-forward", t, [host, sib, t], "s")).toEqual({
403
+ kind: "move",
404
+ displayTrack: 0,
405
+ persistTrack: 3,
406
+ });
407
+ });
408
+ });
409
+
410
+ describe("resolveZMirrorLaneMove — stacking-context (source file) scoping", () => {
411
+ it("other-file clips are not overlap references (extremes computed per file)", () => {
412
+ // A host clip overlaps above the sub-comp child, but the child's own file
413
+ // has nothing above it → bring-to-front is null (already at ITS front).
414
+ const host = el("h", 0, 0, 10);
415
+ const t = el("t", 1, 0, 10, { sourceFile: "sub.html" });
416
+ expect(resolve("bring-to-front", t, [host, t])).toBeNull();
417
+ });
418
+
419
+ it("same-file overlaps in an expanded sub-comp resolve within the child's lanes", () => {
420
+ const host = el("h", 0, 0, 10);
421
+ const sib = el("s", 1, 0, 10, { sourceFile: "sub.html", authoredTrack: 0 });
422
+ const t = el("t", 2, 0, 10, { sourceFile: "sub.html", authoredTrack: 1 });
423
+ // Topmost same-file overlap is sib (lane 1); lane 0 is occupied by the host
424
+ // over the span (freeness is file-agnostic) → insert above sib's lane.
425
+ expect(resolve("bring-to-front", t, [host, sib, t])).toEqual({
426
+ kind: "insert",
427
+ insertRow: 1,
428
+ });
429
+ });
430
+ });
431
+
432
+ describe("resolveZMirrorLaneMove — degenerate inputs and determinism", () => {
433
+ it("zero-duration element returns null", () => {
434
+ const t = el("t", 1, 0, 0);
435
+ const a = el("a", 0, 0, 10);
436
+ expect(resolve("bring-to-front", t, [a, t])).toBeNull();
437
+ });
438
+
439
+ it("single-clip timeline returns null for every action", () => {
440
+ const t = el("t", 0, 0, 10);
441
+ for (const action of [
442
+ "bring-to-front",
443
+ "bring-forward",
444
+ "send-backward",
445
+ "send-to-back",
446
+ ] as const) {
447
+ expect(resolve(action, t, [t])).toBeNull();
448
+ }
449
+ });
450
+
451
+ it("identical inputs produce identical outputs (deterministic, input untouched)", () => {
452
+ const first = sparseAuthored();
453
+ const snapshot = structuredClone(first.elements);
454
+ const r1 = resolve("bring-forward", first.t, first.elements, "b");
455
+ const r2 = resolve("bring-forward", first.t, first.elements, "b");
456
+ const fresh = sparseAuthored();
457
+ const r3 = resolve("bring-forward", fresh.t, fresh.elements, "b");
458
+ expect(r1).toEqual(r2);
459
+ expect(r1).toEqual(r3);
460
+ expect(first.elements).toEqual(snapshot); // pure — never mutates its input
461
+ });
462
+ });
463
+
464
+ describe("resolveRepositionLaneMove (Layers-panel equal jump)", () => {
465
+ // Bottom→top render order helper: keys as the panel's reversed order.
466
+ const reposition = (
467
+ element: TimelineElement,
468
+ elements: TimelineElement[],
469
+ desiredOrderKeys: (string | null)[],
470
+ ) => resolveRepositionLaneMove({ element, elements, desiredOrderKeys });
471
+
472
+ // Three stacked clips on lanes 0/1/2, all overlapping. Render order matches
473
+ // lanes today: bottom→top = c(2), b(1), t(0)... target starts on lane 2.
474
+ const stack3 = () => {
475
+ const a = el("a", 0, 0, 10);
476
+ const b = el("b", 1, 0, 10);
477
+ const t = el("t", 2, 0, 10);
478
+ return { a, b, t, elements: [a, b, t] };
479
+ };
480
+
481
+ it("multi-step jump to the top inserts a new lane above the new below-neighbor", () => {
482
+ const { t, elements } = stack3();
483
+ // t dragged to the TOP of the panel: bottom→top = [b, a, t].
484
+ // New below-neighbor is a (lane 0); lanes above are occupied/none free →
485
+ // insert at a's boundary (order.indexOf(0) = 0).
486
+ expect(reposition(t, elements, ["b", "a", "t"])).toEqual({ kind: "insert", insertRow: 0 });
487
+ });
488
+
489
+ it("multi-step jump lands on a free lane between the new neighbors", () => {
490
+ // Lanes 0,1,2,3: a(0), b(1) short-lived, c(2), t(3). Lane 1 is free over
491
+ // t's span. t dragged between a and c in paint order: above-neighbor a
492
+ // (lane 0), below-neighbor c (lane 2) → free lane 1, strictly between.
493
+ const a = el("a", 0, 0, 10);
494
+ const b = el("b", 1, 20, 5);
495
+ const c = el("c", 2, 0, 10);
496
+ const t = el("t", 3, 0, 10);
497
+ // bottom→top: c (bottom), t (middle), a (top); b not in the sibling set.
498
+ expect(reposition(t, [a, b, c, t], ["c", "t", "a"])).toEqual({
499
+ kind: "move",
500
+ displayTrack: 1,
501
+ persistTrack: 1,
502
+ });
503
+ });
504
+
505
+ it("drop toward the bottom lands on a free lane below the new above-neighbor", () => {
506
+ // Lanes: t(0), b(1), c(2) short-lived → lane 2 free over t's span.
507
+ const t = el("t", 0, 0, 10);
508
+ const b = el("b", 1, 0, 10);
509
+ const c = el("c", 2, 20, 5);
510
+ // t dragged below b: bottom→top = [t, b]. Above-neighbor b (lane 1) →
511
+ // closest free lane below it is lane 2.
512
+ expect(reposition(t, [t, b, c], ["t", "b"])).toEqual({
513
+ kind: "move",
514
+ displayTrack: 2,
515
+ persistTrack: 2,
516
+ });
517
+ });
518
+
519
+ it("drop below the bottom clip inserts a new bottom lane when none is free", () => {
520
+ const x = el("x", 0, 0, 10);
521
+ const y = el("y", 1, 0, 10);
522
+ const z = el("z", 2, 0, 10);
523
+ // x (top lane 0) dragged to the bottom of the panel: bottom→top = [x, z, y].
524
+ // Above-neighbor = z (lane 2); no free lane below it → insert below z
525
+ // (order.indexOf(2) + 1 = 3).
526
+ expect(reposition(x, [x, y, z], ["x", "z", "y"])).toEqual({ kind: "insert", insertRow: 3 });
527
+ });
528
+
529
+ it("skips non-clip siblings (null keys) when resolving neighbors", () => {
530
+ // t on TOP lane 0, a below on lane 1. t dragged below a in paint order,
531
+ // with two decorations (null keys) interleaved: the resolver must skip the
532
+ // nulls and find a (lane 1) as the above-neighbor → no free lane below it
533
+ // → insert below a (order.indexOf(1) + 1 = 2).
534
+ const t = el("t", 0, 0, 10);
535
+ const a = el("a", 1, 0, 10);
536
+ expect(reposition(t, [t, a], [null, "t", null, "a"])).toEqual({
537
+ kind: "insert",
538
+ insertRow: 2,
539
+ });
540
+ });
541
+
542
+ it("returns null when the drop leaves the clip where it already sits", () => {
543
+ const a = el("a", 0, 20, 5); // lane 0 free over t's span but t stays put
544
+ const b = el("b", 1, 0, 10);
545
+ const t = el("t", 2, 0, 10);
546
+ // bottom→top = [t, b] — t stays below b; nearest above-neighbor b (lane 1),
547
+ // scanning down from lane 1 finds t's own lane 2 first.
548
+ expect(reposition(t, [a, b, t], ["t", "b"])).toBeNull();
549
+ });
550
+
551
+ it("returns null for audio, zero-duration, decoration-only sets, and unknown self", () => {
552
+ const t = el("t", 1, 0, 10);
553
+ const a = el("a", 0, 0, 10);
554
+ expect(reposition(audio("s", 3, 0, 10), [t, a], ["s", "t"])).toBeNull();
555
+ expect(reposition(el("z0", 1, 0, 0), [t, a], ["z0", "t"])).toBeNull();
556
+ expect(reposition(t, [t, a], [null, "t", null])).toBeNull(); // no clip neighbor
557
+ expect(reposition(t, [t, a], ["a"])).toBeNull(); // self missing from order
558
+ });
559
+
560
+ it("audio lanes are never targeted (insert stays within the visual zone)", () => {
561
+ const a = el("a", 0, 0, 10);
562
+ const t = el("t", 1, 0, 10);
563
+ const music = audio("m", 2, 0, 30);
564
+ // t dropped below a... wait, t already below a. Drag a below t instead:
565
+ // bottom→top = [a, t]. Above-neighbor t (lane 1); no free visual lane below
566
+ // → insert below t (order.indexOf(1) + 1 = 2), never onto the audio lane.
567
+ expect(reposition(a, [a, t, music], ["a", "t"])).toEqual({ kind: "insert", insertRow: 2 });
568
+ });
569
+
570
+ it("is pure: identical inputs, identical outputs, input untouched", () => {
571
+ const build = () => {
572
+ const a = el("a", 0, 0, 10);
573
+ const b = el("b", 1, 0, 10);
574
+ const t = el("t", 2, 0, 10);
575
+ return { t, elements: [a, b, t] };
576
+ };
577
+ const first = build();
578
+ const snapshot = structuredClone(first.elements);
579
+ const r1 = reposition(first.t, first.elements, ["b", "a", "t"]);
580
+ const fresh = build();
581
+ const r2 = reposition(fresh.t, fresh.elements, ["b", "a", "t"]);
582
+ expect(r1).toEqual(r2);
583
+ expect(first.elements).toEqual(snapshot);
584
+ });
585
+ });
586
+
587
+ describe("paint scope (source file + stacking context)", () => {
588
+ it("forward never references a same-file clip in a DIFFERENT stacking context", () => {
589
+ // b overlaps t and sits above, but lives in a nested stacking context —
590
+ // leaf z is not comparable, so the mirror must not treat it as a neighbor.
591
+ const b = el("b", 0, 0, 10, { stackingContextId: "ctx-nested" });
592
+ const t = el("t", 1, 0, 10);
593
+ expect(resolve("bring-forward", t, [b, t])).toBeNull();
594
+ });
595
+
596
+ it("cross-file clips with matching null root contexts never compare (expanded view)", () => {
597
+ const foreign = el("f", 0, 0, 10, { sourceFile: "scenes/intro.html" });
598
+ const t = el("t", 1, 0, 10);
599
+ expect(resolve("bring-forward", t, [foreign, t])).toBeNull();
600
+ expect(resolve("bring-to-front", t, [foreign, t])).toBeNull();
601
+ });
602
+
603
+ it("reposition skips neighbors outside the paint scope when resolving lanes", () => {
604
+ // t (lane 0) dragged below a in paint order; o sits between them in the
605
+ // DESIRED order but lives in a nested stacking context, so it must be
606
+ // skipped: the above-neighbor is a (lane 1). Lane 2 is occupied by o over
607
+ // the span (freeness is scope-agnostic) → insert below a (row 2).
608
+ const t = el("t", 0, 0, 10);
609
+ const a = el("a", 1, 0, 10);
610
+ const other = el("o", 2, 0, 10, { stackingContextId: "ctx-a" });
611
+ expect(
612
+ resolveRepositionLaneMove({
613
+ element: t,
614
+ elements: [t, a, other],
615
+ desiredOrderKeys: ["t", "o", "a"],
616
+ }),
617
+ ).toEqual({ kind: "insert", insertRow: 2 });
618
+ });
619
+ });
620
+
621
+ describe("expanded sub-comp children — lane scoping", () => {
622
+ const child = (id: string, track: number, start: number, duration: number) =>
623
+ el(id, track, start, duration, {
624
+ sourceFile: "scene.html",
625
+ expandedParentStart: 5,
626
+ });
627
+
628
+ it("a child mirrors onto a SIBLING's lane and persists the sibling's AUTHORED track", () => {
629
+ // Sibling c1 owns fractional display lane 0.25 (authored track 2 in its own
630
+ // file) but sits elsewhere in time → its lane is free over t's span.
631
+ const c1 = el("c1", 0.25, 20, 3, {
632
+ sourceFile: "scene.html",
633
+ expandedParentStart: 5,
634
+ authoredTrack: 2,
635
+ });
636
+ const c2 = child("c2", 0.5, 5, 5);
637
+ const t = child("t", 0.75, 5, 5);
638
+ expect(resolve("bring-forward", t, [c1, c2, t], "c2")).toEqual({
639
+ kind: "move",
640
+ displayTrack: 0.25,
641
+ persistTrack: 2,
642
+ });
643
+ });
644
+
645
+ it("a child NEVER lands on a host-space lane with no same-file occupant", () => {
646
+ // Free integer host lane 0 above the crossed sibling — out of scope for a
647
+ // child; with no sibling lane free either, the mirror refuses (null), it
648
+ // does not insert.
649
+ const host = el("h", 0, 20, 3); // host-space lane, free over the span
650
+ const c2 = child("c2", 0.5, 5, 5);
651
+ const t = child("t", 0.75, 5, 5);
652
+ expect(resolve("bring-forward", t, [host, c2, t], "c2")).toBeNull();
653
+ });
654
+
655
+ it("reposition of a child refuses instead of inserting a host lane", () => {
656
+ const c2 = child("c2", 0.5, 5, 5);
657
+ const t = child("t", 0.75, 5, 5);
658
+ // t dragged above c2 in the layers order; no free sibling lane → null.
659
+ expect(
660
+ resolveRepositionLaneMove({
661
+ element: t,
662
+ elements: [c2, t],
663
+ desiredOrderKeys: ["c2", "t"],
664
+ }),
665
+ ).toBeNull();
666
+ });
667
+ });