@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
@@ -14,7 +14,14 @@ import {
14
14
  } from "../../utils/studioHelpers";
15
15
  import { Layers } from "../../icons/SystemIcons";
16
16
  import { useLayerDrag, isLayerDraggable, type LayerReorderEvent } from "./useLayerDrag";
17
- import { computeReorderZValues, getElementZIndex } from "../../player/lib/layerOrdering";
17
+ import { getVisibleLayers, sortLayersByZIndex } from "./layersPanelSort";
18
+ import { deriveTimelineStoreKey } from "../../player/lib/timelineElementHelpers";
19
+ import { resolveZOrderReposition } from "./canvasContextMenuZOrder";
20
+ import { buildStableSelector } from "./domEditingDom";
21
+ import { zReorderCoalesceKey } from "../../hooks/useElementLifecycleOps";
22
+ import { useLayerReorderTimelineMirror } from "../nle/useCanvasZOrderTimelineMirror";
23
+ import { runZLaneGesture } from "../nle/zLaneGesture";
24
+ import { useLayerRevealOverride } from "./useLayerRevealOverride";
18
25
 
19
26
  const TAG_ICONS: Record<string, string> = {
20
27
  video: "Vi",
@@ -84,8 +91,13 @@ interface CollapsedState {
84
91
  // fallow-ignore-next-line complexity
85
92
  export const LayersPanel = memo(function LayersPanel() {
86
93
  const { previewIframeRef, activeCompPath, showToast } = useStudioShellContext();
87
- const { refreshKey, compositionLoading, timelineElements } = useStudioPlaybackContext();
94
+ const { refreshKey, compositionLoading, timelineElements, isPlaying } =
95
+ useStudioPlaybackContext();
88
96
  const currentTime = usePlayerStore((s) => s.currentTime);
97
+ // Flashless z commits (canvas menu, timeline lane-drag z-sync) mutate iframe
98
+ // z-indexes with no reload and no refreshKey bump — while paused, nothing
99
+ // else re-collects, so the panel's z-sorted order would go stale.
100
+ const zEditVersion = usePlayerStore((s) => s.zEditVersion);
89
101
  const {
90
102
  domEditSelection,
91
103
  activeGroupElement,
@@ -99,6 +111,11 @@ export const LayersPanel = memo(function LayersPanel() {
99
111
  const [collapsed, setCollapsed] = useState<CollapsedState>({});
100
112
  const prevDocVersionRef = useRef(0);
101
113
  const scrollContainerRef = useRef<HTMLDivElement>(null);
114
+ const mirrorLayerReorderToTimeline = useLayerReorderTimelineMirror();
115
+ const { scheduleReveal } = useLayerRevealOverride({
116
+ isPlaying,
117
+ selectedElement: domEditSelection?.element ?? null,
118
+ });
102
119
 
103
120
  const isMasterView = !activeCompPath || activeCompPath === "index.html";
104
121
 
@@ -130,7 +147,7 @@ export const LayersPanel = memo(function LayersPanel() {
130
147
 
131
148
  useEffect(() => {
132
149
  collectLayers();
133
- }, [collectLayers, refreshKey]);
150
+ }, [collectLayers, refreshKey, zEditVersion]);
134
151
 
135
152
  useEffect(() => {
136
153
  const iframe = previewIframeRef.current;
@@ -229,8 +246,13 @@ export const LayersPanel = memo(function LayersPanel() {
229
246
  if (!selection) return;
230
247
  applyDomSelection(selection);
231
248
  await seekToLayer(layer);
249
+ // Force-reveal AFTER the seek's runtime visibility sync has had a beat:
250
+ // a clip made active by the seek shows naturally and needs no override,
251
+ // so the reveal only touches nodes that REMAIN hidden (animation-parked
252
+ // opacity, non-clip display/visibility hides, hidden ancestors).
253
+ scheduleReveal(selection.element, 150);
232
254
  },
233
- [resolveSelection, applyDomSelection, seekToLayer],
255
+ [resolveSelection, applyDomSelection, seekToLayer, scheduleReveal],
234
256
  );
235
257
 
236
258
  // Double-click a group row → drill into it; any other row → select it.
@@ -263,6 +285,7 @@ export const LayersPanel = memo(function LayersPanel() {
263
285
  setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }));
264
286
  }, []);
265
287
 
288
+ // fallow-ignore-next-line complexity
266
289
  const handleReorder = useCallback(
267
290
  (event: LayerReorderEvent) => {
268
291
  const { siblingLayers, fromIndex, toIndex } = event;
@@ -270,21 +293,88 @@ export const LayersPanel = memo(function LayersPanel() {
270
293
  const [moved] = reordered.splice(fromIndex, 1);
271
294
  reordered.splice(toIndex, 0, moved);
272
295
 
273
- const existingValues = siblingLayers.map((l) => getElementZIndex(l.element));
274
- const zValues = computeReorderZValues(existingValues, fromIndex, toIndex);
275
-
276
- const entries = reordered.map((layer, i) => ({
277
- element: layer.element,
278
- zIndex: zValues[i],
279
- id: layer.id,
280
- selector: layer.selector,
281
- selectorIndex: layer.selectorIndex,
282
- sourceFile: layer.sourceFile,
283
- }));
284
-
285
- handleDomZIndexReorderCommit(entries);
296
+ // Panel order is top-first (sortLayersByZIndex: z desc, later-DOM-first),
297
+ // so the desired RENDER order (bottom→top) is the reverse. The minimal
298
+ // resolver (shared with the canvas z-menu) then writes one between-z
299
+ // value when a strict gap exists, band-safe renumber otherwise — instead
300
+ // of the old computeReorderZValues stamp of every sibling.
301
+ const desiredBottomToTop = [...reordered].reverse();
302
+ const patches = resolveZOrderReposition(
303
+ moved.element,
304
+ desiredBottomToTop.map((l) => l.element),
305
+ );
306
+ if (!patches || patches.length === 0) return; // paint order unchanged
307
+
308
+ const layerByElement = new Map(siblingLayers.map((l) => [l.element, l]));
309
+ const entries: Array<{
310
+ element: HTMLElement;
311
+ zIndex: number;
312
+ id?: string;
313
+ selector?: string;
314
+ selectorIndex?: number;
315
+ sourceFile: string;
316
+ key?: string;
317
+ }> = [];
318
+ for (const patch of patches) {
319
+ // The renumber fallback can patch a painting sibling the panel didn't
320
+ // list (non-collected family member): derive its identity from the DOM
321
+ // node, exactly like the canvas menu's siblingZIndexEntry. Un-targetable
322
+ // nodes get a live-only style write (reverts on reload).
323
+ const layer = layerByElement.get(patch.element);
324
+ const id = layer?.id ?? (patch.element.id || undefined);
325
+ const selector = layer?.selector ?? buildStableSelector(patch.element);
326
+ if (!id && !selector) {
327
+ patch.element.style.zIndex = String(patch.zIndex);
328
+ continue;
329
+ }
330
+ const sourceFile = layer?.sourceFile ?? moved.sourceFile;
331
+ entries.push({
332
+ element: patch.element,
333
+ zIndex: patch.zIndex,
334
+ id,
335
+ selector,
336
+ selectorIndex: layer?.selectorIndex,
337
+ sourceFile,
338
+ key: deriveTimelineStoreKey({
339
+ domId: id,
340
+ selector,
341
+ selectorIndex: layer?.selectorIndex,
342
+ sourceFile,
343
+ }),
344
+ });
345
+ }
346
+ if (entries.length === 0) return;
347
+
348
+ // ONE undo entry for the whole gesture: the z persist and the timeline
349
+ // lane mirror below share this per-gesture-unique key (same contract as
350
+ // the canvas menu's wiring in PreviewOverlays).
351
+ const coalesceKey = zReorderCoalesceKey(entries, "layer-drag");
352
+ const desiredOrderKeys = desiredBottomToTop.map(
353
+ (l) =>
354
+ deriveTimelineStoreKey({
355
+ domId: l.id,
356
+ selector: l.selector,
357
+ selectorIndex: l.selectorIndex,
358
+ sourceFile: l.sourceFile,
359
+ }) ?? null,
360
+ );
361
+ const movedKey = deriveTimelineStoreKey({
362
+ domId: moved.id,
363
+ selector: moved.selector,
364
+ selectorIndex: moved.selectorIndex,
365
+ sourceFile: moved.sourceFile,
366
+ });
367
+ // One serialized z→lane transaction (shared queue with the canvas
368
+ // z-order menu): the mirror runs only after a DURABLE z persist, and
369
+ // rapid successive gestures cannot interleave phases — see
370
+ // runZLaneGesture.
371
+ runZLaneGesture({
372
+ commitZ: () => handleDomZIndexReorderCommit(entries, coalesceKey, "layer-drag"),
373
+ mirror: () =>
374
+ mirrorLayerReorderToTimeline({ selectionKey: movedKey, desiredOrderKeys, coalesceKey }),
375
+ }).catch(() => undefined);
286
376
  },
287
- [handleDomZIndexReorderCommit],
377
+ [handleDomZIndexReorderCommit, mirrorLayerReorderToTimeline],
288
378
  );
289
379
 
290
380
  const selectedKey = domEditSelection ? getDomEditLayerKey(domEditSelection) : null;
@@ -427,76 +517,6 @@ export const LayersPanel = memo(function LayersPanel() {
427
517
  );
428
518
  });
429
519
 
430
- // ── Pure helpers ──────────────────────────────────────────────────────
431
-
432
- // fallow-ignore-next-line complexity
433
- export function sortLayersByZIndex(layers: DomEditLayerItem[]): DomEditLayerItem[] {
434
- if (layers.length <= 1) return layers;
435
-
436
- const minDepth = layers[0].depth;
437
- for (let i = 1; i < layers.length; i++) {
438
- if (layers[i].depth < minDepth) return layers;
439
- }
440
-
441
- const chunks: Array<{ root: DomEditLayerItem; children: DomEditLayerItem[]; domIndex: number }> =
442
- [];
443
-
444
- for (let i = 0; i < layers.length; i++) {
445
- if (layers[i].depth === minDepth) {
446
- const children: DomEditLayerItem[] = [];
447
- let j = i + 1;
448
- while (j < layers.length && layers[j].depth > minDepth) {
449
- children.push(layers[j]);
450
- j++;
451
- }
452
- chunks.push({ root: layers[i], children, domIndex: chunks.length });
453
- }
454
- }
455
-
456
- if (chunks.length <= 1) {
457
- if (chunks.length === 1 && chunks[0].children.length > 0) {
458
- const sorted = sortLayersByZIndex(chunks[0].children);
459
- return [chunks[0].root, ...sorted];
460
- }
461
- return layers;
462
- }
463
-
464
- chunks.sort((a, b) => {
465
- const zA = getElementZIndex(a.root.element);
466
- const zB = getElementZIndex(b.root.element);
467
- if (zA !== zB) return zB - zA;
468
- return b.domIndex - a.domIndex;
469
- });
470
-
471
- const result: DomEditLayerItem[] = [];
472
- for (const chunk of chunks) {
473
- result.push(chunk.root);
474
- if (chunk.children.length > 0) {
475
- result.push(...sortLayersByZIndex(chunk.children));
476
- }
477
- }
478
- return result;
479
- }
480
-
481
- function getVisibleLayers(
482
- layers: DomEditLayerItem[],
483
- collapsed: CollapsedState,
484
- ): DomEditLayerItem[] {
485
- if (Object.keys(collapsed).length === 0) return layers;
486
-
487
- const result: DomEditLayerItem[] = [];
488
- let skipDepth = -1;
489
-
490
- for (const layer of layers) {
491
- if (skipDepth >= 0 && layer.depth > skipDepth) continue;
492
- skipDepth = -1;
493
-
494
- result.push(layer);
495
-
496
- if (collapsed[layer.key] && layer.childCount > 0) {
497
- skipDepth = layer.depth;
498
- }
499
- }
500
-
501
- return result;
502
- }
520
+ // The sort helper lives in layersPanelSort.ts (600-line studio cap);
521
+ // re-exported so existing imports from "./LayersPanel" still resolve.
522
+ export { sortLayersByZIndex } from "./layersPanelSort";
@@ -1,9 +1,12 @@
1
1
  // @vitest-environment jsdom
2
2
  import { describe, expect, it } from "vitest";
3
3
  import {
4
+ isElementVisibleForZOrder,
4
5
  isZOrderActionEnabled,
5
6
  parseZIndex,
7
+ resolveCrossedNeighbor,
6
8
  resolveZOrderChange,
9
+ resolveZOrderReposition,
7
10
  type ZOrderAction,
8
11
  type ZOrderPatch,
9
12
  } from "./canvasContextMenuZOrder";
@@ -48,8 +51,12 @@ function makeFamily(
48
51
  }
49
52
 
50
53
  /** Resolve a z-order change and assert it produced patches (fails otherwise). */
51
- function resolveZOrderPatches(target: HTMLElement, action: ZOrderAction): ZOrderPatch[] {
52
- const patches = resolveZOrderChange(target, action);
54
+ function resolveZOrderPatches(
55
+ target: HTMLElement,
56
+ action: ZOrderAction,
57
+ options?: { isVisible?: (el: HTMLElement) => boolean },
58
+ ): ZOrderPatch[] {
59
+ const patches = resolveZOrderChange(target, action, options);
53
60
  expect(patches).not.toBeNull();
54
61
  if (!patches) throw new Error("expected z-order patches");
55
62
  return patches;
@@ -375,6 +382,27 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
375
382
  expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
376
383
  });
377
384
 
385
+ it("ignores <template>/<noscript> siblings in the family", () => {
386
+ // A renumber fallback once wrote z-index/position into <template> source
387
+ // markup because templates entered the sibling family. They never paint —
388
+ // exclude them like audio/script/style.
389
+ const parent = document.createElement("div");
390
+ const a = makeEl("a", "0");
391
+ const template = document.createElement("template");
392
+ template.style.zIndex = "2";
393
+ const noscript = document.createElement("noscript");
394
+ const target = makeEl("target", "0");
395
+ parent.append(a, template, noscript, target);
396
+
397
+ const patches = resolveZOrderPatches(target, "send-to-back");
398
+ for (const p of patches) {
399
+ expect(p.element).not.toBe(template);
400
+ expect(p.element).not.toBe(noscript);
401
+ }
402
+ const order = renderOrderIds(parent, { a, target }, patches);
403
+ expect(order.indexOf("target")).toBeLessThan(order.indexOf("a"));
404
+ });
405
+
378
406
  it("a lone painting element beside only non-painting siblings has no family → null", () => {
379
407
  const parent = document.createElement("div");
380
408
  const target = makeEl("target", "1");
@@ -392,6 +420,163 @@ describe("resolveZOrderChange – excludes non-painting siblings", () => {
392
420
  });
393
421
  });
394
422
 
423
+ // ── visibility scoping (forward/backward step over VISIBLE siblings only) ──────
424
+ //
425
+ // The visibility probe is injectable (ZOrderResolveOptions.isVisible) exactly
426
+ // like rect reading is stubbable — these tests drive the scoping logic with a
427
+ // stub, without a real style engine.
428
+
429
+ describe("resolveZOrderChange – visibility scoping (injectable stub)", () => {
430
+ /** Probe that hides exactly the given elements. */
431
+ function hiding(...hidden: HTMLElement[]) {
432
+ return { isVisible: (el: HTMLElement) => !hidden.includes(el) };
433
+ }
434
+
435
+ it("bring-forward steps over the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
436
+ // Render order: target(1), hidden(2), vis(3). The nearest z-neighbor above
437
+ // is invisible at the current frame — forward must land the target above
438
+ // `vis` (the next VISIBLE overlapping sibling), leaving `hidden` untouched.
439
+ const { target, byId } = makeFamily("1", [
440
+ ["hidden", "2"],
441
+ ["vis", "3"],
442
+ ]);
443
+ const patches = resolveZOrderPatches(target, "bring-forward", hiding(byId.hidden!));
444
+ expect(patches).toHaveLength(1);
445
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(4);
446
+ expect(patchFor(patches, byId, "hidden")).toBeUndefined();
447
+ });
448
+
449
+ it("send-backward steps below the next VISIBLE sibling, ignoring an invisible z-neighbor", () => {
450
+ // Render order: vis(1), hidden(2), target(3). Backward must drop the target
451
+ // below `vis`, not merely below the invisible `hidden`.
452
+ const { target, byId } = makeFamily("3", [
453
+ ["vis", "1"],
454
+ ["hidden", "2"],
455
+ ]);
456
+ const patches = resolveZOrderPatches(target, "send-backward", hiding(byId.hidden!));
457
+ expect(patches).toHaveLength(1);
458
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(0);
459
+ expect(patchFor(patches, byId, "hidden")).toBeUndefined();
460
+ });
461
+
462
+ it("forward/backward are no-ops when every overlapping sibling is invisible", () => {
463
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
464
+ const opts = hiding(byId.hidden!);
465
+ expect(resolveZOrderChange(target, "bring-forward", opts)).toBeNull();
466
+ expect(resolveZOrderChange(target, "send-backward", opts)).toBeNull();
467
+ });
468
+
469
+ it("bring-to-front / send-to-back keep the FULL painting family (invisible siblings included)", () => {
470
+ // Unchanged semantics: front/back operate across all siblings, so an
471
+ // invisible sibling still counts and the actions stay meaningful.
472
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
473
+ const opts = hiding(byId.hidden!);
474
+ const patches = resolveZOrderPatches(target, "bring-to-front", opts);
475
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
476
+ expect(resolveZOrderChange(target, "send-to-back", opts)).toBeNull(); // already bottom
477
+ });
478
+
479
+ it("the target itself is retained even when the probe reports it invisible", () => {
480
+ const { target, byId } = makeFamily("1", [["vis", "2"]]);
481
+ const patches = resolveZOrderPatches(target, "bring-forward", hiding(target));
482
+ expect(patchFor(patches, byId, "target")?.zIndex).toBe(3);
483
+ });
484
+
485
+ it("isZOrderActionEnabled matches the resolver under the same visibility scope", () => {
486
+ const { target, byId } = makeFamily("1", [["hidden", "2"]]);
487
+ const opts = hiding(byId.hidden!);
488
+ // Forward/backward: scoped set collapses to the target alone → disabled.
489
+ expect(isZOrderActionEnabled(target, "bring-forward", opts)).toBe(false);
490
+ expect(isZOrderActionEnabled(target, "send-backward", opts)).toBe(false);
491
+ // Front/back: full family → enabled exactly where the resolver acts.
492
+ expect(isZOrderActionEnabled(target, "bring-to-front", opts)).toBe(true);
493
+ expect(isZOrderActionEnabled(target, "send-to-back", opts)).toBe(false);
494
+ });
495
+ });
496
+
497
+ // ── default visibility probe (element-level computed style) ───────────────────
498
+
499
+ describe("isElementVisibleForZOrder – default probe", () => {
500
+ function attachedEl(style: Partial<CSSStyleDeclaration> = {}): HTMLElement {
501
+ const el = document.createElement("div");
502
+ Object.assign(el.style, style);
503
+ document.body.appendChild(el);
504
+ return el;
505
+ }
506
+
507
+ it("treats display:none / visibility:hidden / opacity≈0 as invisible", () => {
508
+ expect(isElementVisibleForZOrder(attachedEl({ display: "none" }))).toBe(false);
509
+ expect(isElementVisibleForZOrder(attachedEl({ visibility: "hidden" }))).toBe(false);
510
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0" }))).toBe(false);
511
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.005" }))).toBe(false);
512
+ });
513
+
514
+ it("treats normal, translucent, and unstyled elements as visible", () => {
515
+ expect(isElementVisibleForZOrder(attachedEl())).toBe(true);
516
+ expect(isElementVisibleForZOrder(attachedEl({ opacity: "0.5" }))).toBe(true);
517
+ expect(isElementVisibleForZOrder(attachedEl({ visibility: "visible" }))).toBe(true);
518
+ });
519
+
520
+ it("exempts a hidden color-grading source (its canvas paints in its place)", () => {
521
+ const el = attachedEl({ opacity: "0" });
522
+ el.setAttribute("data-hf-color-grading-source-hidden", "");
523
+ expect(isElementVisibleForZOrder(el)).toBe(true);
524
+ });
525
+
526
+ it("is the default probe: the runtime's inline visibility:hidden on a time-inactive clip is skipped", () => {
527
+ // End-to-end through resolveZOrderChange with NO injected probe: the
528
+ // runtime hides inactive clips with inline `visibility:hidden` (see core
529
+ // runtime syncTimedElementVisibility) — computed style picks that up.
530
+ const parent = document.createElement("div");
531
+ const target = makeEl("target", "1");
532
+ const hidden = makeEl("hidden", "2");
533
+ hidden.style.visibility = "hidden";
534
+ const vis = makeEl("vis", "3");
535
+ parent.append(target, hidden, vis);
536
+ document.body.appendChild(parent);
537
+ const patches = resolveZOrderPatches(target, "bring-forward");
538
+ expect(patchFor(patches, { target, hidden, vis }, "target")?.zIndex).toBe(4);
539
+ expect(patchFor(patches, { target, hidden, vis }, "hidden")).toBeUndefined();
540
+ });
541
+ });
542
+
543
+ // ── resolveCrossedNeighbor (the "show your work" flash target) ────────────────
544
+
545
+ describe("resolveCrossedNeighbor", () => {
546
+ it("returns the visible sibling directly above for bring-forward", () => {
547
+ const { target, byId } = makeFamily("1", [
548
+ ["hidden", "2"],
549
+ ["vis", "3"],
550
+ ]);
551
+ const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
552
+ expect(resolveCrossedNeighbor(target, "bring-forward", opts)).toBe(byId.vis);
553
+ });
554
+
555
+ it("returns the visible sibling directly below for send-backward", () => {
556
+ const { target, byId } = makeFamily("3", [
557
+ ["vis", "1"],
558
+ ["hidden", "2"],
559
+ ]);
560
+ const opts = { isVisible: (el: HTMLElement) => el !== byId.hidden };
561
+ expect(resolveCrossedNeighbor(target, "send-backward", opts)).toBe(byId.vis);
562
+ });
563
+
564
+ it("returns null for front/back actions and for no-op steps", () => {
565
+ const { target } = makeFamily("1", [["a", "2"]]);
566
+ expect(resolveCrossedNeighbor(target, "bring-to-front")).toBeNull();
567
+ expect(resolveCrossedNeighbor(target, "send-to-back")).toBeNull();
568
+ expect(resolveCrossedNeighbor(target, "send-backward")).toBeNull(); // already bottom
569
+ const { target: top } = makeFamily("5", [["a", "2"]]);
570
+ expect(resolveCrossedNeighbor(top, "bring-forward")).toBeNull(); // already top
571
+ });
572
+
573
+ it("returns null when there are no siblings", () => {
574
+ const solo = makeEl("solo", "1");
575
+ document.createElement("div").appendChild(solo);
576
+ expect(resolveCrossedNeighbor(solo, "bring-forward")).toBeNull();
577
+ });
578
+ });
579
+
395
580
  // ── isZOrderActionEnabled ─────────────────────────────────────────────────────
396
581
 
397
582
  describe("isZOrderActionEnabled", () => {
@@ -433,3 +618,59 @@ describe("isZOrderActionEnabled", () => {
433
618
  }
434
619
  });
435
620
  });
621
+
622
+ describe("resolveZOrderReposition (Layers-panel arbitrary drop)", () => {
623
+ it("multi-position jump with distinct z resolves to ONE between-z write", () => {
624
+ // Render order bottom→top today: target(1), a(3), b(5). Drop target between
625
+ // a and b → single write: z strictly between 3 and 5.
626
+ const { target, byId } = makeFamily("1", [
627
+ ["a", "3"],
628
+ ["b", "5"],
629
+ ]);
630
+ const patches = resolveZOrderReposition(target, [byId.a, target, byId.b]);
631
+ expect(patches).toEqual([{ element: target, zIndex: 4 }]);
632
+ });
633
+
634
+ it("jump to the very top writes one z above the previous top", () => {
635
+ const { target, byId } = makeFamily("1", [
636
+ ["a", "3"],
637
+ ["b", "5"],
638
+ ]);
639
+ const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
640
+ expect(patches).toEqual([{ element: target, zIndex: 6 }]);
641
+ });
642
+
643
+ it("no-op drop (unchanged order) returns null", () => {
644
+ const { target, byId } = makeFamily("1", [
645
+ ["a", "3"],
646
+ ["b", "5"],
647
+ ]);
648
+ expect(resolveZOrderReposition(target, [target, byId.a, byId.b])).toBeNull();
649
+ });
650
+
651
+ it("tied z values renumber the scoped set minimally (band-safe)", () => {
652
+ const { target, byId } = makeFamily("2", [
653
+ ["a", "2"],
654
+ ["b", "2"],
655
+ ]);
656
+ // All tied at 2; DOM order target,a,b → render bottom→top target,a,b.
657
+ // Move target to the top: scoped renumber within the band.
658
+ const patches = resolveZOrderReposition(target, [byId.a, byId.b, target]);
659
+ expect(patches).not.toBeNull();
660
+ const z = new Map(patches!.map((p) => [(p.element as HTMLElement).id, p.zIndex]));
661
+ const zOf = (id: string) => z.get(id) ?? 2;
662
+ expect(zOf("a")).toBeLessThan(zOf("b"));
663
+ expect(zOf("b")).toBeLessThan(zOf("target"));
664
+ });
665
+
666
+ it("rejects elements that are not painting siblings of the target", () => {
667
+ const { target, byId } = makeFamily("1", [["a", "3"]]);
668
+ const stranger = makeEl("stranger", "2");
669
+ expect(resolveZOrderReposition(target, [stranger, target, byId.a])).toBeNull();
670
+ });
671
+
672
+ it("returns null for sets too small to reorder", () => {
673
+ const { target } = makeFamily("1", []);
674
+ expect(resolveZOrderReposition(target, [target])).toBeNull();
675
+ });
676
+ });