@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
@@ -46,6 +46,7 @@ import { scrubMusicAtSeek, stopScrubPreviewAudio } from "../lib/playbackScrub";
46
46
  import { applyCachedSourceDurations, probeMissingSourceDurations } from "../lib/mediaProbe";
47
47
  import { shouldResumeForwardPlaybackAfterSeek, shouldStopAfterSeek } from "../lib/playbackSeek";
48
48
  import { applyPreviewVariablesToUrl } from "../../hooks/previewVariablesStore";
49
+ import { acceptStudioRuntimeMessage } from "../lib/runtimeProtocol";
49
50
 
50
51
  /**
51
52
  * Whether the derived elements differ from the current ones in any field that
@@ -492,6 +493,9 @@ export function useTimelinePlayer() {
492
493
  if (e.source && ourIframe && e.source !== ourIframe.contentWindow) {
493
494
  return;
494
495
  }
496
+ if (data?.source === "hf-preview") {
497
+ if (!acceptStudioRuntimeMessage(data)) return;
498
+ }
495
499
  if (data?.source === "hf-preview" && data?.type === "state") {
496
500
  try {
497
501
  if (usePlayerStore.getState().elements.length === 0) {
@@ -11,6 +11,7 @@
11
11
  import { useCallback } from "react";
12
12
  import { liveTime, usePlayerStore } from "../store/playerStore";
13
13
  import type { TimelineElement, DomClipChild } from "../store/playerStore";
14
+ import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context";
14
15
  import type { PlaybackAdapter, ClipManifestClip, IframeWindow } from "../lib/playbackTypes";
15
16
  import {
16
17
  parseTimelineFromDOM,
@@ -26,6 +27,7 @@ import {
26
27
  autoHealMissingCompositionIds,
27
28
  buildMissingCompositionElements,
28
29
  } from "../lib/timelineIframeHelpers";
30
+ import { acceptedRuntimeMessageFps, inspectStudioRuntimeMessage } from "../lib/runtimeProtocol";
29
31
 
30
32
  interface UseTimelineSyncCallbacksParams {
31
33
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -132,6 +134,9 @@ export function useTimelineSyncCallbacks({
132
134
  clips: ClipManifestClip[];
133
135
  durationInFrames: number;
134
136
  scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
137
+ protocolVersion?: unknown;
138
+ capabilities?: unknown;
139
+ fps?: unknown;
135
140
  }) => {
136
141
  if (!data.clips || data.clips.length === 0) {
137
142
  return;
@@ -195,6 +200,7 @@ export function useTimelineSyncCallbacks({
195
200
  parentId,
196
201
  hostId,
197
202
  label: isGroup ? child.getAttribute("data-hf-group") || child.id : child.id,
203
+ stackingContextId: resolveCssStackingContextId(child),
198
204
  });
199
205
  parentMap.set(child.id, parentId);
200
206
  if (isGroup) collect(child, child.id);
@@ -222,7 +228,7 @@ export function useTimelineSyncCallbacks({
222
228
  hostEl,
223
229
  });
224
230
  });
225
- const rawDuration = data.durationInFrames / 30;
231
+ const rawDuration = data.durationInFrames / acceptedRuntimeMessageFps(data);
226
232
  // Clamp non-finite or absurdly large durations — the runtime can emit
227
233
  // Infinity when it detects a loop-inflated GSAP timeline without an
228
234
  // explicit data-duration on the root composition. Floor the manifest total
@@ -418,6 +424,10 @@ export function useTimelineSyncCallbacks({
418
424
  if (e.source && iframe && e.source !== iframe.contentWindow) return;
419
425
  const data = e.data;
420
426
  if (data?.source === "hf-preview" && (data?.type === "state" || data?.type === "timeline")) {
427
+ // The main message handler owns protocol-error diagnostics. This readiness-only
428
+ // listener mirrors its acceptance gate without dispatching a duplicate event:
429
+ // an unsupported runtime must not make the iframe appear successfully settled.
430
+ if (inspectStudioRuntimeMessage(data).status === "unsupported") return;
421
431
  trySettle();
422
432
  }
423
433
  };
@@ -11,7 +11,9 @@ export { resolveIframe } from "./lib/timelineDOM";
11
11
 
12
12
  // Store
13
13
  export { usePlayerStore, liveTime } from "./store/playerStore";
14
- export type { SelectElementOptions, TimelineElement } from "./store/playerStore";
14
+ // Public library surface; external consumers are invisible to the workspace analyzer.
15
+ // fallow-ignore-next-line unused-exports
16
+ export type { SelectElementOptions, TimelineElement, ZoomMode } from "./store/playerStore";
15
17
 
16
18
  // Utils
17
19
  export { formatTime } from "./lib/time";
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
- import { computeReorderZValues, getElementZIndex, resolveContextOrder } from "./layerOrdering";
4
+ import { getElementZIndex, resolveContextOrder } from "./layerOrdering";
5
5
 
6
6
  function makeElement(zIndex?: string): HTMLElement {
7
7
  const element = document.createElement("div");
@@ -31,20 +31,6 @@ describe("getElementZIndex", () => {
31
31
  });
32
32
  });
33
33
 
34
- describe("computeReorderZValues", () => {
35
- it("preserves distinct existing z-index values and remaps them onto the new order", () => {
36
- expect(computeReorderZValues([1, 8, 3], 0, 2)).toEqual([8, 3, 1]);
37
- });
38
-
39
- it("renumbers an all-tied group to descending contiguous z-index values", () => {
40
- expect(computeReorderZValues([0, 0, 0], 2, 0)).toEqual([3, 2, 1]);
41
- });
42
-
43
- it("renumbers the whole group when any existing z-index values are tied", () => {
44
- expect(computeReorderZValues([5, 5, 1], 2, 0)).toEqual([3, 2, 1]);
45
- });
46
- });
47
-
48
34
  describe("resolveContextOrder", () => {
49
35
  it("sorts a flat sibling group by z-index descending then original order", () => {
50
36
  const ordered = resolveContextOrder([
@@ -1,3 +1,5 @@
1
+ import { readLayerRevealPriorZ } from "./timelineElementHelpers";
2
+
1
3
  export interface StackingContextDescriptor {
2
4
  parentCompositionId: string | null;
3
5
  compositionAncestors: readonly string[];
@@ -11,6 +13,9 @@ export interface ContextOrderItem extends StackingContextDescriptor {
11
13
  // fallow-ignore-next-line complexity
12
14
  export function getElementZIndex(element: HTMLElement): number {
13
15
  try {
16
+ // An active Layers-panel reveal lift reports the element's TRUE z.
17
+ const prior = readLayerRevealPriorZ(element);
18
+ if (prior != null) return prior;
14
19
  const inline = element.style?.zIndex;
15
20
  if (inline && inline !== "auto") {
16
21
  const parsed = parseInt(inline, 10);
@@ -27,34 +32,6 @@ export function getElementZIndex(element: HTMLElement): number {
27
32
  }
28
33
  }
29
34
 
30
- // fallow-ignore-next-line complexity
31
- export function hasExplicitZIndex(element: HTMLElement): boolean {
32
- try {
33
- const inline = element.style?.zIndex;
34
- if (inline) return inline !== "auto";
35
- const win = element.ownerDocument?.defaultView;
36
- if (!win) return false;
37
- const value = win.getComputedStyle(element).zIndex;
38
- return value !== "auto" && value !== "";
39
- } catch {
40
- return false;
41
- }
42
- }
43
-
44
- export function computeReorderZValues(
45
- existingValues: readonly number[],
46
- fromIndex: number,
47
- toIndex: number,
48
- ): number[] {
49
- const reordered = [...existingValues];
50
- const [moved] = reordered.splice(fromIndex, 1);
51
- reordered.splice(toIndex, 0, moved);
52
-
53
- const sorted = [...existingValues].sort((a, b) => b - a);
54
- const hasDupes = sorted.some((v, i) => i > 0 && v === sorted[i - 1]);
55
- return hasDupes ? reordered.map((_, i) => reordered.length - i) : sorted;
56
- }
57
-
58
35
  export function resolveStackingContextKey(item: StackingContextDescriptor): string {
59
36
  return item.stackingContextId ?? item.parentCompositionId ?? item.compositionAncestors[0] ?? "";
60
37
  }
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ acceptStudioRuntimeMessage,
4
+ acceptedRuntimeMessageFps,
5
+ createRuntimeControlMessage,
6
+ inspectStudioRuntimeMessage,
7
+ postRuntimeControlMessage,
8
+ } from "./runtimeProtocol";
9
+
10
+ describe("Studio runtime protocol", () => {
11
+ it("versions every control message and declares rational fps", () => {
12
+ expect(createRuntimeControlMessage("seek", { timeSeconds: 1.25 }, 60)).toEqual({
13
+ source: "hf-parent",
14
+ type: "control",
15
+ action: "seek",
16
+ protocolVersion: 1,
17
+ capabilities: ["seconds-time", "rational-fps", "seek-keep-playing"],
18
+ fps: { numerator: 60, denominator: 1 },
19
+ timeSeconds: 1.25,
20
+ });
21
+ });
22
+
23
+ it("posts the typed message to the target window", () => {
24
+ const target = { postMessage: vi.fn() };
25
+ postRuntimeControlMessage(target as unknown as Window, "pause");
26
+ expect(target.postMessage).toHaveBeenCalledWith(
27
+ expect.objectContaining({ action: "pause", protocolVersion: 1 }),
28
+ "*",
29
+ );
30
+ });
31
+
32
+ it("preserves legacy 30fps messages and rejects unknown majors", () => {
33
+ expect(inspectStudioRuntimeMessage({ source: "hf-preview" })).toEqual({
34
+ status: "legacy",
35
+ fps: 30,
36
+ });
37
+ expect(inspectStudioRuntimeMessage({ protocolVersion: 2 })).toMatchObject({
38
+ status: "unsupported",
39
+ code: "unsupported_protocol_version",
40
+ });
41
+ });
42
+
43
+ it("reads explicit fps for accepted timeline messages", () => {
44
+ const message = createRuntimeControlMessage("pause", {}, 60);
45
+ expect(acceptedRuntimeMessageFps(message)).toBe(60);
46
+ expect(acceptStudioRuntimeMessage(message)).toMatchObject({ status: "supported", fps: 60 });
47
+ });
48
+ });
@@ -0,0 +1,65 @@
1
+ import {
2
+ inspectRuntimeProtocol,
3
+ runtimeProtocolMetadata,
4
+ type RuntimeProtocolInspection,
5
+ } from "@hyperframes/core/runtime/protocol";
6
+
7
+ export type RuntimeControlMessage = {
8
+ source: "hf-parent";
9
+ type: "control";
10
+ action: string;
11
+ } & ReturnType<typeof runtimeProtocolMetadata> &
12
+ Record<string, unknown>;
13
+
14
+ export function createRuntimeControlMessage(
15
+ action: string,
16
+ payload: Record<string, unknown> = {},
17
+ fps = 30,
18
+ ): RuntimeControlMessage {
19
+ return {
20
+ ...payload,
21
+ source: "hf-parent",
22
+ type: "control",
23
+ action,
24
+ ...runtimeProtocolMetadata(fps),
25
+ };
26
+ }
27
+
28
+ export function postRuntimeControlMessage(
29
+ target: Pick<Window, "postMessage"> | null | undefined,
30
+ action: string,
31
+ payload: Record<string, unknown> = {},
32
+ fps = 30,
33
+ ): void {
34
+ target?.postMessage(createRuntimeControlMessage(action, payload, fps), "*");
35
+ }
36
+
37
+ export function inspectStudioRuntimeMessage(value: unknown): RuntimeProtocolInspection {
38
+ return inspectRuntimeProtocol(value, 30);
39
+ }
40
+
41
+ function dispatchRuntimeProtocolError(inspection: RuntimeProtocolInspection): void {
42
+ if (inspection.status !== "unsupported") return;
43
+ window.dispatchEvent(
44
+ new CustomEvent("runtimeprotocolerror", {
45
+ detail: {
46
+ code: inspection.code,
47
+ receivedVersion: inspection.receivedVersion,
48
+ },
49
+ }),
50
+ );
51
+ }
52
+
53
+ export function acceptStudioRuntimeMessage(
54
+ value: unknown,
55
+ ): Exclude<RuntimeProtocolInspection, { status: "unsupported" }> | null {
56
+ const inspection = inspectStudioRuntimeMessage(value);
57
+ if (inspection.status !== "unsupported") return inspection;
58
+ dispatchRuntimeProtocolError(inspection);
59
+ return null;
60
+ }
61
+
62
+ export function acceptedRuntimeMessageFps(value: unknown): number {
63
+ const inspection = inspectStudioRuntimeMessage(value);
64
+ return inspection.status === "supported" ? inspection.fps : 30;
65
+ }
@@ -110,7 +110,79 @@ describe("parseTimelineFromDOM — hfId from data-hf-id", () => {
110
110
  });
111
111
  });
112
112
 
113
+ describe("createTimelineElementFromManifestClip — source-scoped selector identity", () => {
114
+ it("ignores an index.html duplicate when indexing a scene.html selector", () => {
115
+ const doc = makeDoc(`
116
+ <div data-composition-id="root" data-composition-file="index.html">
117
+ <div class="sub"></div>
118
+ <div data-composition-id="scene" data-composition-file="scene.html">
119
+ <div class="sub"></div>
120
+ <div class="sub" data-target></div>
121
+ </div>
122
+ </div>
123
+ `);
124
+ const target = doc.querySelector("[data-target]");
125
+ if (!target) throw new Error("missing target");
126
+
127
+ const element = createTimelineElementFromManifestClip({
128
+ clip: {
129
+ id: null,
130
+ label: "Sub",
131
+ kind: "element",
132
+ tagName: "div",
133
+ start: 0,
134
+ duration: 5,
135
+ track: 0,
136
+ compositionId: null,
137
+ parentCompositionId: null,
138
+ compositionSrc: null,
139
+ assetUrl: null,
140
+ },
141
+ fallbackIndex: 0,
142
+ doc,
143
+ hostEl: target,
144
+ });
145
+
146
+ expect(element.sourceFile).toBe("scene.html");
147
+ expect(element.selectorIndex).toBe(1);
148
+ expect(element.key).toBe("scene.html:.sub:1");
149
+ });
150
+ });
151
+
113
152
  describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => {
153
+ it("uses the runtime root paint scope for implicit siblings of manifest clips", () => {
154
+ const doc = makeDoc(`
155
+ <div data-composition-id="root">
156
+ <div id="timed" data-start="0" data-duration="5"></div>
157
+ <div id="implicit"></div>
158
+ </div>
159
+ `);
160
+ const timedHost = doc.getElementById("timed");
161
+ const timed = createTimelineElementFromManifestClip({
162
+ clip: {
163
+ id: "timed",
164
+ label: "Timed",
165
+ start: 0,
166
+ duration: 5,
167
+ track: 0,
168
+ stackingContextId: "css:root",
169
+ kind: "element",
170
+ tagName: "div",
171
+ compositionId: null,
172
+ parentCompositionId: null,
173
+ compositionSrc: null,
174
+ assetUrl: null,
175
+ },
176
+ fallbackIndex: 0,
177
+ doc,
178
+ hostEl: timedHost,
179
+ });
180
+ const implicit = createImplicitTimelineLayersFromDOM(doc, 5, [timed])[0];
181
+
182
+ expect(implicit?.stackingContextId).toBe("css:root");
183
+ expect(implicit?.stackingContextId).toBe(timed.stackingContextId);
184
+ });
185
+
114
186
  it("harvests hfId from an implicit layer child that has data-hf-id", () => {
115
187
  const doc = makeDoc(`
116
188
  <div data-composition-id="root">
@@ -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,
@@ -113,6 +114,15 @@ export function createTimelineElementFromManifestClip(params: {
113
114
  start: clip.start,
114
115
  duration: clip.duration,
115
116
  track: clip.track,
117
+ // clip.track IS the authored data-track-index verbatim (the runtime honors
118
+ // it; see parseAuthoredTrack in core/runtime/timeline.ts). Record it at this
119
+ // translation boundary so later display-lane remaps (normalizeToZones,
120
+ // expanded-child rows) can persist in AUTHORED space instead of
121
+ // reconstructing it from lane occupants.
122
+ authoredTrack: clip.track,
123
+ // Runtime-computed stacking context — authoritative; helpers read it, never
124
+ // re-derive it.
125
+ stackingContextId: clip.stackingContextId ?? null,
116
126
  domId,
117
127
  hfId,
118
128
  selector,
@@ -219,6 +229,7 @@ export function createImplicitTimelineLayersFromDOM(
219
229
  selector,
220
230
  selectorIndex,
221
231
  sourceFile,
232
+ stackingContextId: resolveCssStackingContextId(child),
222
233
  start: 0,
223
234
  tag: child.tagName.toLowerCase(),
224
235
  timingSource: "implicit",
@@ -294,6 +305,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel
294
305
  selector,
295
306
  selectorIndex,
296
307
  sourceFile,
308
+ stackingContextId: resolveCssStackingContextId(el),
297
309
  timingSource: "authored",
298
310
  zIndex: readTimelineElementZIndex(el),
299
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: {
@@ -298,6 +322,25 @@ export function getTimelineElementIdentity(element: { key?: string | null; id: s
298
322
  return element.key ?? element.id;
299
323
  }
300
324
 
325
+ /**
326
+ * Timeline store key for a z-reorder entry built OUTSIDE the timeline
327
+ * expansion (canvas context menu / LayersPanel), so the reorder commit can
328
+ * update the store's zIndex synchronously. Matches buildTimelineElementKey's
329
+ * stable branches (`sourceFile#domId`, else the selector-based key). Undefined
330
+ * when the element has neither a DOM id nor a selector — the timeline's
331
+ * fallback branch needs its own fallbackIndex, which these callers don't have,
332
+ * so such an entry simply skips the synchronous store update.
333
+ */
334
+ export function deriveTimelineStoreKey(params: {
335
+ domId?: string;
336
+ selector?: string;
337
+ selectorIndex?: number;
338
+ sourceFile?: string;
339
+ }): string | undefined {
340
+ if (!params.domId && !params.selector) return undefined;
341
+ return buildTimelineElementKey({ id: "", fallbackIndex: 0, ...params });
342
+ }
343
+
301
344
  // ---------------------------------------------------------------------------
302
345
  // DOM node querying
303
346
  // ---------------------------------------------------------------------------
@@ -20,6 +20,7 @@ import {
20
20
  buildTimelineElementIdentity,
21
21
  readTimelineElementZIndex,
22
22
  } from "./timelineElementHelpers";
23
+ import { postRuntimeControlMessage } from "./runtimeProtocol";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // Viewport / DOM normalisation
@@ -103,10 +104,7 @@ function postPreviewControl(
103
104
  action: string,
104
105
  payload: Record<string, unknown>,
105
106
  ): void {
106
- iframe.contentWindow?.postMessage(
107
- { source: "hf-parent", type: "control", action, ...payload },
108
- "*",
109
- );
107
+ postRuntimeControlMessage(iframe.contentWindow, action, payload);
110
108
  }
111
109
 
112
110
  export function shouldMutePreviewAudio(audioMuted: boolean, playbackRate: number): boolean {
@@ -1,6 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, vi } from "vitest";
2
2
  import { usePlayerStore, liveTime, type TimelineElement } from "./playerStore";
3
3
 
4
+ /** The playback/selection state `reset()` restores (persistent prefs asserted separately). */
5
+ function expectResettableDefaults(state: ReturnType<typeof usePlayerStore.getState>): void {
6
+ expect(state.isPlaying).toBe(false);
7
+ expect(state.currentTime).toBe(0);
8
+ expect(state.duration).toBe(0);
9
+ expect(state.timelineReady).toBe(false);
10
+ expect(state.elements).toEqual([]);
11
+ expect(state.selectedElementId).toBeNull();
12
+ }
13
+
4
14
  describe("usePlayerStore", () => {
5
15
  beforeEach(() => {
6
16
  usePlayerStore.getState().reset();
@@ -9,12 +19,7 @@ describe("usePlayerStore", () => {
9
19
  describe("initial state", () => {
10
20
  it("has correct defaults", () => {
11
21
  const state = usePlayerStore.getState();
12
- expect(state.isPlaying).toBe(false);
13
- expect(state.currentTime).toBe(0);
14
- expect(state.duration).toBe(0);
15
- expect(state.timelineReady).toBe(false);
16
- expect(state.elements).toEqual([]);
17
- expect(state.selectedElementId).toBeNull();
22
+ expectResettableDefaults(state);
18
23
  expect(state.playbackRate).toBe(1);
19
24
  expect(state.audioMuted).toBe(false);
20
25
  expect(state.loopEnabled).toBe(false);
@@ -384,6 +389,32 @@ describe("usePlayerStore", () => {
384
389
  });
385
390
  });
386
391
 
392
+ describe("clipRevealRequest", () => {
393
+ it("starts null and carries the requested element id", () => {
394
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
395
+ usePlayerStore.getState().requestClipReveal("el-1");
396
+ expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
397
+ });
398
+
399
+ it("bumps the nonce on repeat requests for the same clip", () => {
400
+ usePlayerStore.getState().requestClipReveal("el-1");
401
+ const first = usePlayerStore.getState().clipRevealRequest;
402
+ usePlayerStore.getState().requestClipReveal("el-1");
403
+ const second = usePlayerStore.getState().clipRevealRequest;
404
+ expect(second?.nonce).not.toBe(first?.nonce);
405
+ });
406
+
407
+ it("clears via clearClipRevealRequest and on reset", () => {
408
+ usePlayerStore.getState().requestClipReveal("el-1");
409
+ usePlayerStore.getState().clearClipRevealRequest();
410
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
411
+
412
+ usePlayerStore.getState().requestClipReveal("el-2");
413
+ usePlayerStore.getState().reset();
414
+ expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
415
+ });
416
+ });
417
+
387
418
  describe("reset", () => {
388
419
  it("resets all state to defaults", () => {
389
420
  // Mutate everything
@@ -398,13 +429,7 @@ describe("usePlayerStore", () => {
398
429
  // Reset
399
430
  usePlayerStore.getState().reset();
400
431
 
401
- const state = usePlayerStore.getState();
402
- expect(state.isPlaying).toBe(false);
403
- expect(state.currentTime).toBe(0);
404
- expect(state.duration).toBe(0);
405
- expect(state.timelineReady).toBe(false);
406
- expect(state.elements).toEqual([]);
407
- expect(state.selectedElementId).toBeNull();
432
+ expectResettableDefaults(usePlayerStore.getState());
408
433
  });
409
434
 
410
435
  it("does not reset playbackRate, audioMuted, loopEnabled, zoomMode, or manualZoomPercent", () => {