@hyperframes/studio 0.7.90 → 0.7.93

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 (170) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CquRJgm-.js → index-CCGnc_0M.js} +1 -1
  3. package/dist/assets/index-D78KEjgB.css +1 -0
  4. package/dist/assets/index-Dp1zVb9X.js +428 -0
  5. package/dist/assets/{index-Bi-CwQDX.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +40 -36
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +9598 -6798
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/App.tsx +14 -14
  12. package/src/components/DesignPanelPromoteProvider.tsx +1 -1
  13. package/src/components/ExternalFileConflictBanner.test.tsx +142 -0
  14. package/src/components/ExternalFileConflictBanner.tsx +255 -0
  15. package/src/components/StudioRightPanel.tsx +1 -1
  16. package/src/components/editor/GsapAnimationList.tsx +84 -0
  17. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  18. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  19. package/src/components/editor/PropertyPanel.tsx +1 -1
  20. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  21. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  22. package/src/components/editor/propertyPanel3dTransform.tsx +1 -1
  23. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  24. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  25. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  26. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  27. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  28. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  29. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  30. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  31. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  32. package/src/components/editor/propertyPanelTypes.ts +10 -4
  33. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  34. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  36. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  37. package/src/components/renders/useRenderQueue.ts +16 -3
  38. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  39. package/src/components/sidebar/AssetCard.tsx +4 -3
  40. package/src/components/sidebar/AudioRow.tsx +4 -3
  41. package/src/contexts/DomEditContext.tsx +1 -1
  42. package/src/contexts/FileManagerContext.tsx +12 -0
  43. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  44. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  45. package/src/hooks/externalFileReloadBus.ts +18 -0
  46. package/src/hooks/gsapDragCommit.test.ts +39 -1
  47. package/src/hooks/gsapDragCommit.ts +12 -40
  48. package/src/hooks/gsapEditOutcome.ts +72 -0
  49. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  50. package/src/hooks/gsapResizeIntercept.ts +44 -16
  51. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  52. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  53. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  54. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  55. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  56. package/src/hooks/useDomEditSession.test.tsx +1 -1
  57. package/src/hooks/useDomEditSession.ts +1 -1
  58. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  59. package/src/hooks/useDomGeometryCommits.ts +20 -0
  60. package/src/hooks/useEditorSave.test.tsx +131 -0
  61. package/src/hooks/useEditorSave.ts +113 -39
  62. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  63. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  64. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  65. package/src/hooks/useFileManager.ts +32 -3
  66. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  67. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  68. package/src/hooks/useGsapAwareEditing.ts +88 -27
  69. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  70. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  71. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  72. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  73. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  74. package/src/hooks/usePreviewPersistence.ts +22 -58
  75. package/src/hooks/useSdkSession.ts +8 -52
  76. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  77. package/src/hooks/useStudioSdkSessions.ts +2 -7
  78. package/src/hooks/useStudioSessionStart.ts +15 -0
  79. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  80. package/src/player/components/BeatStrip.test.tsx +374 -0
  81. package/src/player/components/BeatStrip.tsx +295 -37
  82. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  83. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  84. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  85. package/src/player/components/Timeline.test.ts +26 -8
  86. package/src/player/components/Timeline.tsx +87 -88
  87. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  88. package/src/player/components/TimelineCanvas.tsx +14 -71
  89. package/src/player/components/TimelineClip.test.tsx +17 -2
  90. package/src/player/components/TimelineClip.tsx +20 -5
  91. package/src/player/components/TimelineClipDiamonds.test.tsx +536 -27
  92. package/src/player/components/TimelineClipDiamonds.tsx +118 -223
  93. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  94. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  95. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  96. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  97. package/src/player/components/TimelineLanes.test.tsx +64 -7
  98. package/src/player/components/TimelineLanes.tsx +84 -66
  99. package/src/player/components/TimelineOverlays.test.ts +161 -0
  100. package/src/player/components/TimelineOverlays.tsx +104 -11
  101. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  102. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  103. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  104. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  105. package/src/player/components/TimelineTrackRow.tsx +58 -7
  106. package/src/player/components/timelineCallbacks.ts +1 -1
  107. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  108. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  109. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  110. package/src/player/components/timelineClipDragPreview.ts +18 -12
  111. package/src/player/components/timelineClipDragTypes.ts +12 -0
  112. package/src/player/components/timelineDiamondTypes.ts +18 -21
  113. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  114. package/src/player/components/timelineDragDrop.ts +152 -30
  115. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  116. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  117. package/src/player/components/timelineLaneProps.ts +3 -1
  118. package/src/player/components/timelineMarquee.test.ts +73 -25
  119. package/src/player/components/timelineMarquee.ts +44 -16
  120. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  121. package/src/player/components/timelineTestViewport.ts +13 -0
  122. package/src/player/components/timelineViewModel.ts +4 -4
  123. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  124. package/src/player/components/useTimelineClipDrag.ts +112 -70
  125. package/src/player/components/useTimelineClipRenderWindow.ts +8 -39
  126. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  127. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  128. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  129. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  130. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  131. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  132. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  133. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  134. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  135. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  136. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  137. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  138. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  139. package/src/player/components/useTimelineRowVirtualization.ts +26 -29
  140. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  141. package/src/player/components/useTrackGapMenu.ts +32 -9
  142. package/src/player/store/keyframeSlice.ts +59 -18
  143. package/src/player/store/liveTime.ts +11 -0
  144. package/src/player/store/playerStore.test.ts +106 -23
  145. package/src/player/store/playerStore.ts +43 -40
  146. package/src/player/store/timelineFocusState.ts +15 -0
  147. package/src/telemetry/canary.test.ts +325 -0
  148. package/src/telemetry/canary.ts +268 -0
  149. package/src/telemetry/client.test.ts +8 -3
  150. package/src/telemetry/client.ts +13 -43
  151. package/src/telemetry/config.ts +16 -2
  152. package/src/telemetry/distinctId.test.ts +35 -0
  153. package/src/telemetry/distinctId.ts +11 -2
  154. package/src/telemetry/policy.test.ts +81 -0
  155. package/src/telemetry/policy.ts +104 -0
  156. package/src/utils/domEditSaveQueue.test.ts +37 -1
  157. package/src/utils/domEditSaveQueue.ts +22 -4
  158. package/src/utils/externalConflictStorage.test.ts +95 -0
  159. package/src/utils/externalConflictStorage.ts +172 -0
  160. package/src/utils/studioFileVersion.test.ts +28 -1
  161. package/src/utils/studioFileVersion.ts +30 -0
  162. package/src/utils/studioPendingEdits.test.ts +108 -1
  163. package/src/utils/studioPendingEdits.ts +39 -2
  164. package/src/utils/studioSaveDiagnostics.ts +5 -0
  165. package/src/utils/studioTelemetry.test.ts +76 -0
  166. package/src/utils/studioTelemetry.ts +14 -6
  167. package/dist/assets/index-BXkzp_OU.css +0 -1
  168. package/dist/assets/index-Cql15Yur.js +0 -428
  169. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  170. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -20,30 +20,23 @@ interface UseTimelineRowVirtualizationInput {
20
20
  viewport: TimelineScrollViewportSnapshot;
21
21
  rowGeometry: TimelineRowGeometry;
22
22
  sessionEpoch: number;
23
- elements: TimelineElement[];
23
+ elements: readonly TimelineElement[];
24
24
  selectedElementId: string | null;
25
- revealElementId: string | null;
25
+ focusedRowKey?: number;
26
26
  draggedRowKey?: number;
27
- resizingRowKey?: number;
27
+ resizingElementIds?: readonly string[];
28
28
  clipContextMenuRowKey?: number;
29
29
  keyframeContextMenuRowKey?: number;
30
30
  lastScrollLeftRef: RefObject<number>;
31
31
  syncScrollViewport: (element: HTMLDivElement, isScrolling?: boolean) => void;
32
32
  }
33
33
 
34
- interface TimelineDomFocusPin {
35
- readonly rowKey?: number;
36
- readonly elementId?: string;
37
- }
38
-
39
- function getTimelineDomFocusPin(target: EventTarget | null): TimelineDomFocusPin | undefined {
34
+ function getFocusedTimelineRowKey(target: EventTarget | null): number | undefined {
40
35
  if (!(target instanceof Element)) return undefined;
41
36
  const value = target.closest<HTMLElement>("[data-timeline-row-key]")?.dataset.timelineRowKey;
42
- const parsedRowKey = value === undefined ? undefined : Number(value);
43
- const rowKey =
44
- parsedRowKey !== undefined && Number.isFinite(parsedRowKey) ? parsedRowKey : undefined;
45
- const elementId = target.closest<HTMLElement>("[data-el-id]")?.dataset.elId;
46
- return rowKey === undefined && elementId === undefined ? undefined : { rowKey, elementId };
37
+ if (value === undefined) return undefined;
38
+ const rowKey = Number(value);
39
+ return Number.isFinite(rowKey) ? rowKey : undefined;
47
40
  }
48
41
 
49
42
  export function useTimelineRowVirtualization({
@@ -53,45 +46,50 @@ export function useTimelineRowVirtualization({
53
46
  sessionEpoch,
54
47
  elements,
55
48
  selectedElementId,
56
- revealElementId,
49
+ focusedRowKey,
57
50
  draggedRowKey,
58
- resizingRowKey,
51
+ resizingElementIds,
59
52
  clipContextMenuRowKey,
60
53
  keyframeContextMenuRowKey,
61
54
  lastScrollLeftRef,
62
55
  syncScrollViewport,
63
56
  }: UseTimelineRowVirtualizationInput) {
64
57
  const enabled = STUDIO_TIMELINE_ROW_VIRTUALIZATION_ENABLED;
65
- const [domFocusPin, setDomFocusPin] = useState<TimelineDomFocusPin>();
58
+ const [domFocusedRowKey, setDomFocusedRowKey] = useState<number>();
66
59
  const onTimelineFocus = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
67
- setDomFocusPin(getTimelineDomFocusPin(event.target));
60
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.target));
68
61
  }, []);
69
62
  const onTimelineBlur = useCallback((event: ReactFocusEvent<HTMLDivElement>) => {
70
- setDomFocusPin(getTimelineDomFocusPin(event.relatedTarget));
63
+ setDomFocusedRowKey(getFocusedTimelineRowKey(event.relatedTarget));
71
64
  }, []);
72
- const focusIdentity = useMemo(
65
+ const selectedIdentity = useMemo(
73
66
  () => resolveTimelineFocusIdentity(elements, selectedElementId),
74
67
  [elements, selectedElementId],
75
68
  );
76
- const revealIdentity = useMemo(
77
- () => resolveTimelineFocusIdentity(elements, revealElementId),
78
- [elements, revealElementId],
69
+ const resizingRowKeys = useMemo(
70
+ () =>
71
+ resizingElementIds
72
+ ?.map((elementId) => resolveTimelineFocusIdentity(elements, elementId)?.rowKey)
73
+ .filter((rowKey): rowKey is number => rowKey !== undefined) ?? [],
74
+ [elements, resizingElementIds],
79
75
  );
80
76
  const pinnedRowKeys = useMemo(
81
77
  () =>
82
78
  [
83
79
  draggedRowKey,
84
- resizingRowKey,
85
- revealIdentity?.rowKey,
80
+ ...resizingRowKeys,
81
+ selectedIdentity?.rowKey,
82
+ focusedRowKey,
86
83
  clipContextMenuRowKey,
87
84
  keyframeContextMenuRowKey,
88
85
  ].filter((rowKey): rowKey is number => rowKey !== undefined),
89
86
  [
90
87
  clipContextMenuRowKey,
91
88
  draggedRowKey,
89
+ focusedRowKey,
92
90
  keyframeContextMenuRowKey,
93
- resizingRowKey,
94
- revealIdentity,
91
+ resizingRowKeys,
92
+ selectedIdentity,
95
93
  ],
96
94
  );
97
95
  const virtualRows = useTimelineVirtualRows({
@@ -101,7 +99,7 @@ export function useTimelineRowVirtualization({
101
99
  rowGeometry,
102
100
  sessionEpoch,
103
101
  pinnedRowKeys,
104
- focusedRowKey: domFocusPin?.rowKey ?? focusIdentity?.rowKey,
102
+ focusedRowKey: domFocusedRowKey ?? focusedRowKey,
105
103
  });
106
104
 
107
105
  const previousLayoutRef = useRef(rowGeometry);
@@ -134,7 +132,6 @@ export function useTimelineRowVirtualization({
134
132
  return {
135
133
  enabled,
136
134
  virtualRows,
137
- focusedElementId: domFocusPin?.elementId,
138
135
  timelineFocusProps: { onFocus: onTimelineFocus, onBlur: onTimelineBlur },
139
136
  };
140
137
  }
@@ -0,0 +1,64 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
7
+ import { useTrackGapMenu } from "./useTrackGapMenu";
8
+
9
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ const lane: TimelineElement[] = [
12
+ { id: "first", tag: "div", start: 0, duration: 1, track: 0 },
13
+ { id: "second", tag: "div", start: 3, duration: 1, track: 0 },
14
+ ];
15
+
16
+ afterEach(() => {
17
+ usePlayerStore.getState().reset();
18
+ document.body.innerHTML = "";
19
+ });
20
+
21
+ describe("useTrackGapMenu", () => {
22
+ it("does not commit an anchor captured in a previous project session", () => {
23
+ usePlayerStore.setState({ elements: lane, timelineSessionEpoch: 1 });
24
+ const host = document.createElement("div");
25
+ document.body.append(host);
26
+ const root = createRoot(host);
27
+ const onMoveElement = vi.fn();
28
+ const onMoveElements = vi.fn();
29
+ let api: ReturnType<typeof useTrackGapMenu> | null = null;
30
+
31
+ function getApi(): ReturnType<typeof useTrackGapMenu> {
32
+ if (!api) throw new Error("gap menu harness did not render");
33
+ return api;
34
+ }
35
+
36
+ function Probe() {
37
+ api = useTrackGapMenu({
38
+ tracks: [[0, lane]],
39
+ expandedElementsRef: { current: lane },
40
+ trackOrderRef: { current: [0] },
41
+ onMoveElement,
42
+ onMoveElements,
43
+ });
44
+ return null;
45
+ }
46
+
47
+ act(() => root.render(<Probe />));
48
+ act(() => getApi().openGapMenu({ x: 10, y: 20, track: 0, time: 2 }));
49
+ expect(getApi().gapMenuModel).not.toBeNull();
50
+ const staleCloseTrackGap = getApi().closeTrackGap;
51
+ const staleCloseAllTrackGaps = getApi().closeAllTrackGaps;
52
+
53
+ act(() => {
54
+ usePlayerStore.setState({ timelineSessionEpoch: 2 });
55
+ staleCloseTrackGap();
56
+ staleCloseAllTrackGaps();
57
+ });
58
+ expect(getApi().gapMenuModel).toBeNull();
59
+
60
+ expect(onMoveElement).not.toHaveBeenCalled();
61
+ expect(onMoveElements).not.toHaveBeenCalled();
62
+ act(() => root.unmount());
63
+ });
64
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useMemo, useState, type MutableRefObject } from "react";
1
+ import { useCallback, useEffect, useMemo, useState, type MutableRefObject } from "react";
2
2
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
3
3
  import type { DragCommitDeps } from "./timelineClipDragCommit";
4
4
  import {
@@ -21,6 +21,7 @@ interface TrackGapMenuAnchor {
21
21
  y: number;
22
22
  track: number;
23
23
  time: number;
24
+ sessionEpoch: number;
24
25
  }
25
26
 
26
27
  /** Gap strips to paint on one lane while a menu row is hovered. */
@@ -32,9 +33,9 @@ export interface TrackGapHighlight {
32
33
  /**
33
34
  * Track-gap context menu (right-click on empty lane space) — state, the
34
35
  * derived menu model, and the two commit actions. Extracted from Timeline.tsx
35
- * as a cohesive unit (600-line studio cap); behavior identical.
36
+ * as a cohesive unit (600-line studio cap).
36
37
  *
37
- * Only the ANCHOR (and the hovered row) is state; the menu model (gap under
38
+ * Only the session-stamped ANCHOR (and the hovered row) is state; the menu model (gap under
38
39
  * the pointer, compaction, movability) derives from live `tracks` so an open
39
40
  * menu reflects concurrent edits. `gapHighlight` — the strips TimelineCanvas
40
41
  * paints while "Close gap" / "Close all gaps" is hovered — derives the same
@@ -55,12 +56,16 @@ export function useTrackGapMenu({
55
56
  onMoveElements: DragCommitDeps["onMoveElements"];
56
57
  }) {
57
58
  const updateElement = usePlayerStore((s) => s.updateElement);
59
+ const sessionEpoch = usePlayerStore((s) => s.timelineSessionEpoch);
58
60
  const [gapContextMenu, setGapContextMenu] = useState<TrackGapMenuAnchor | null>(null);
59
61
  const [hoveredGapAction, setHoveredGapAction] = useState<"close-gap" | "close-all" | null>(null);
60
62
 
61
63
  const gapMenuLaneElements = useMemo(
62
- () => (gapContextMenu ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? []) : null),
63
- [gapContextMenu, tracks],
64
+ () =>
65
+ gapContextMenu?.sessionEpoch === sessionEpoch
66
+ ? (tracks.find(([t]) => t === gapContextMenu.track)?.[1] ?? [])
67
+ : null,
68
+ [gapContextMenu, sessionEpoch, tracks],
64
69
  );
65
70
  const gapMenuModel = useMemo(() => {
66
71
  if (!gapContextMenu || !gapMenuLaneElements) return null;
@@ -99,7 +104,12 @@ export function useTrackGapMenu({
99
104
  }, [gapContextMenu, gapMenuLaneElements, hoveredGapAction]);
100
105
 
101
106
  const closeTrackGap = useCallback(() => {
102
- if (!gapContextMenu || !gapMenuLaneElements) return;
107
+ if (
108
+ !gapContextMenu ||
109
+ !gapMenuLaneElements ||
110
+ gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
111
+ )
112
+ return;
103
113
  commitCloseTrackGap(gapMenuLaneElements, gapContextMenu.time, {
104
114
  elements: expandedElementsRef.current,
105
115
  trackOrder: trackOrderRef.current,
@@ -117,7 +127,12 @@ export function useTrackGapMenu({
117
127
  onMoveElements,
118
128
  ]);
119
129
  const closeAllTrackGaps = useCallback(() => {
120
- if (!gapMenuLaneElements) return;
130
+ if (
131
+ !gapContextMenu ||
132
+ !gapMenuLaneElements ||
133
+ gapContextMenu.sessionEpoch !== usePlayerStore.getState().timelineSessionEpoch
134
+ )
135
+ return;
121
136
  commitCloseAllTrackGaps(gapMenuLaneElements, {
122
137
  elements: expandedElementsRef.current,
123
138
  trackOrder: trackOrderRef.current,
@@ -126,6 +141,7 @@ export function useTrackGapMenu({
126
141
  onMoveElements,
127
142
  });
128
143
  }, [
144
+ gapContextMenu,
129
145
  gapMenuLaneElements,
130
146
  expandedElementsRef,
131
147
  trackOrderRef,
@@ -134,15 +150,22 @@ export function useTrackGapMenu({
134
150
  onMoveElements,
135
151
  ]);
136
152
 
137
- const openGapMenu = useCallback((anchor: TrackGapMenuAnchor) => {
153
+ const openGapMenu = useCallback((anchor: Omit<TrackGapMenuAnchor, "sessionEpoch">) => {
138
154
  setHoveredGapAction(null);
139
- setGapContextMenu(anchor);
155
+ setGapContextMenu({
156
+ ...anchor,
157
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
158
+ });
140
159
  }, []);
141
160
  const dismissGapMenu = useCallback(() => {
142
161
  setHoveredGapAction(null);
143
162
  setGapContextMenu(null);
144
163
  }, []);
145
164
 
165
+ useEffect(() => {
166
+ if (gapContextMenu && gapContextMenu.sessionEpoch !== sessionEpoch) dismissGapMenu();
167
+ }, [dismissGapMenu, gapContextMenu, sessionEpoch]);
168
+
146
169
  return {
147
170
  gapMenuModel,
148
171
  gapHighlight,
@@ -22,6 +22,34 @@ export interface KeyframeCacheEntry {
22
22
  easeEach?: string;
23
23
  }
24
24
 
25
+ export interface FocusedEaseSegment {
26
+ animationId: string;
27
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
28
+ tweenPercentage: number;
29
+ elementId: string;
30
+ projectId: string | null;
31
+ sessionEpoch: number;
32
+ nonce: number;
33
+ }
34
+
35
+ type FocusedEaseSegmentTarget = Omit<FocusedEaseSegment, "projectId" | "sessionEpoch" | "nonce">;
36
+
37
+ interface TimelineSessionIdentity {
38
+ timelineProjectId: string | null;
39
+ timelineSessionEpoch: number;
40
+ }
41
+
42
+ export function isFocusedEaseRequestCurrent(
43
+ request: FocusedEaseSegment,
44
+ state: TimelineSessionIdentity & { selectedElementId: string | null },
45
+ ): boolean {
46
+ return (
47
+ request.projectId === state.timelineProjectId &&
48
+ request.sessionEpoch === state.timelineSessionEpoch &&
49
+ request.elementId === state.selectedElementId
50
+ );
51
+ }
52
+
25
53
  export interface KeyframeSlice {
26
54
  /** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
27
55
  selectedKeyframes: Set<string>;
@@ -35,22 +63,14 @@ export interface KeyframeSlice {
35
63
  /** Union-expand clips (keyframed clips are expanded by default on load). */
36
64
  expandClips: (ids: readonly string[]) => void;
37
65
 
38
- /** elementId scopes the request to one element so a shared (class-selector)
39
- * animation id can't open the ease editor on the wrong element. */
40
- focusedEaseSegment: {
41
- animationId: string;
42
- collidingAnimationTargets?: AnimationKeyframeTarget[];
43
- tweenPercentage: number;
44
- elementId: string;
45
- } | null;
46
- setFocusedEaseSegment: (
47
- target: {
48
- animationId: string;
49
- collidingAnimationTargets?: AnimationKeyframeTarget[];
50
- tweenPercentage: number;
51
- elementId: string;
52
- } | null,
53
- ) => void;
66
+ /**
67
+ * Project/session/element-scoped request. Its nonce is monotonic across store
68
+ * resets so a stale consumer can never collide with a later request.
69
+ */
70
+ focusedEaseSegment: FocusedEaseSegment | null;
71
+ focusedEaseRequestNonce: number;
72
+ setFocusedEaseSegment: (target: FocusedEaseSegmentTarget) => void;
73
+ clearFocusedEaseSegment: (nonce: number) => void;
54
74
 
55
75
  /** Keyframe data per element id, populated from parsed GSAP animations. */
56
76
  keyframeCache: Map<string, KeyframeCacheEntry>;
@@ -60,7 +80,10 @@ export interface KeyframeSlice {
60
80
  setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
61
81
  }
62
82
 
63
- export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): KeyframeSlice {
83
+ export function createKeyframeSlice(
84
+ set: StoreApi<KeyframeSlice>["setState"],
85
+ getTimelineSessionIdentity: () => TimelineSessionIdentity,
86
+ ): KeyframeSlice {
64
87
  return {
65
88
  selectedKeyframes: new Set(),
66
89
  toggleSelectedKeyframe: (key) =>
@@ -97,7 +120,25 @@ export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): K
97
120
  }),
98
121
 
99
122
  focusedEaseSegment: null,
100
- setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
123
+ focusedEaseRequestNonce: 0,
124
+ setFocusedEaseSegment: (target) =>
125
+ set((state) => {
126
+ const nonce = state.focusedEaseRequestNonce + 1;
127
+ const { timelineProjectId, timelineSessionEpoch } = getTimelineSessionIdentity();
128
+ return {
129
+ focusedEaseRequestNonce: nonce,
130
+ focusedEaseSegment: {
131
+ ...target,
132
+ projectId: timelineProjectId,
133
+ sessionEpoch: timelineSessionEpoch,
134
+ nonce,
135
+ },
136
+ };
137
+ }),
138
+ clearFocusedEaseSegment: (nonce) =>
139
+ set((state) =>
140
+ state.focusedEaseSegment?.nonce === nonce ? { focusedEaseSegment: null } : state,
141
+ ),
101
142
 
102
143
  keyframeCache: new Map(),
103
144
  setKeyframeCache: (elementId, data) =>
@@ -0,0 +1,11 @@
1
+ // ponytail: Playback RAF updates the playhead/time display without per-frame React renders.
2
+ type TimeListener = (time: number) => void;
3
+ const timeListeners = new Set<TimeListener>();
4
+
5
+ export const liveTime = {
6
+ notify: (time: number) => timeListeners.forEach((listener) => listener(time)),
7
+ subscribe: (listener: TimeListener) => {
8
+ timeListeners.add(listener);
9
+ return () => timeListeners.delete(listener);
10
+ },
11
+ };
@@ -53,6 +53,82 @@ describe("usePlayerStore", () => {
53
53
  });
54
54
  });
55
55
 
56
+ describe("focused ease requests", () => {
57
+ it("stamps the current project session and only lets its nonce clear it", () => {
58
+ const store = usePlayerStore.getState();
59
+ store.beginTimelineSession("project-a");
60
+ store.setSelectedElementId("index.html#hero");
61
+ store.setFocusedEaseSegment({
62
+ elementId: "index.html#hero",
63
+ animationId: "animation-a",
64
+ tweenPercentage: 50,
65
+ });
66
+ const first = usePlayerStore.getState().focusedEaseSegment;
67
+ if (!first) throw new Error("expected focused ease request");
68
+ expect(first.projectId).toBe("project-a");
69
+ expect(first.sessionEpoch).toBeGreaterThan(0);
70
+ expect(first.nonce).toBeGreaterThan(0);
71
+
72
+ store.setFocusedEaseSegment({
73
+ elementId: "index.html#hero",
74
+ animationId: "animation-a",
75
+ tweenPercentage: 75,
76
+ });
77
+ const second = usePlayerStore.getState().focusedEaseSegment;
78
+ if (!second) throw new Error("expected replacement request");
79
+ expect(second.nonce).toBe(first.nonce + 1);
80
+
81
+ store.clearFocusedEaseSegment(first.nonce);
82
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(second);
83
+ store.clearFocusedEaseSegment(second.nonce);
84
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
85
+ });
86
+
87
+ it("clears a pending request when the project session changes", () => {
88
+ const store = usePlayerStore.getState();
89
+ store.beginTimelineSession("project-a");
90
+ store.setFocusedEaseSegment({
91
+ elementId: "index.html#hero",
92
+ animationId: "animation-a",
93
+ tweenPercentage: 50,
94
+ });
95
+
96
+ store.beginTimelineSession("project-b");
97
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
98
+ });
99
+
100
+ it("does not revive an old request after selecting away and back", () => {
101
+ const store = usePlayerStore.getState();
102
+ store.setSelectedElementId("index.html#a");
103
+ store.setFocusedEaseSegment({
104
+ elementId: "index.html#a",
105
+ animationId: "animation-a",
106
+ tweenPercentage: 50,
107
+ });
108
+
109
+ store.setSelectedElementId("index.html#b");
110
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
111
+ store.setSelectedElementId("index.html#a");
112
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
113
+ });
114
+
115
+ it("invalidates on a genuine selection-anchor change but not a same-anchor echo", () => {
116
+ const store = usePlayerStore.getState();
117
+ store.setSelection(new Set(["index.html#a", "index.html#b"]), "index.html#a");
118
+ store.setFocusedEaseSegment({
119
+ elementId: "index.html#a",
120
+ animationId: "animation-a",
121
+ tweenPercentage: 50,
122
+ });
123
+ const request = usePlayerStore.getState().focusedEaseSegment;
124
+
125
+ store.setSelectionAnchor("index.html#a");
126
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(request);
127
+ store.setSelectionAnchor("index.html#b");
128
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
129
+ });
130
+ });
131
+
56
132
  describe("setIsPlaying", () => {
57
133
  it("sets isPlaying to true", () => {
58
134
  usePlayerStore.getState().setIsPlaying(true);
@@ -416,29 +492,36 @@ describe("usePlayerStore", () => {
416
492
  });
417
493
  });
418
494
 
419
- describe("clipRevealRequest", () => {
420
- it("starts null and carries the requested element id", () => {
421
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
422
- usePlayerStore.getState().requestClipReveal("el-1");
423
- expect(usePlayerStore.getState().clipRevealRequest?.elementId).toBe("el-1");
424
- });
425
-
426
- it("bumps the nonce on repeat requests for the same clip", () => {
427
- usePlayerStore.getState().requestClipReveal("el-1");
428
- const first = usePlayerStore.getState().clipRevealRequest;
429
- usePlayerStore.getState().requestClipReveal("el-1");
430
- const second = usePlayerStore.getState().clipRevealRequest;
431
- expect(second?.nonce).not.toBe(first?.nonce);
432
- });
433
-
434
- it("clears via clearClipRevealRequest and on reset", () => {
435
- usePlayerStore.getState().requestClipReveal("el-1");
436
- usePlayerStore.getState().clearClipRevealRequest();
437
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
438
-
439
- usePlayerStore.getState().requestClipReveal("el-2");
440
- usePlayerStore.getState().reset();
441
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
495
+ describe("timelineFocus", () => {
496
+ it("stamps project scope and carries the requested logical id", () => {
497
+ usePlayerStore.getState().beginTimelineSession("project-a");
498
+ usePlayerStore.getState().requestTimelineFocus("clip:el-1");
499
+ expect(usePlayerStore.getState().timelineFocus).toMatchObject({
500
+ id: "clip:el-1",
501
+ projectId: "project-a",
502
+ sessionEpoch: usePlayerStore.getState().timelineSessionEpoch,
503
+ });
504
+ const store = usePlayerStore.getState();
505
+ store.requestTimelineFocus("clip:el-1");
506
+ const first = usePlayerStore.getState().timelineFocus;
507
+ if (!first) throw new Error("expected timeline focus request");
508
+ store.clearTimelineFocus(first.nonce);
509
+ store.reset();
510
+ store.requestTimelineFocus("clip:el-1");
511
+ const second = usePlayerStore.getState().timelineFocus;
512
+ expect(second?.nonce).toBe(first.nonce + 1);
513
+
514
+ store.beginTimelineSession("project-a");
515
+ store.requestTimelineFocus("clip:el-1");
516
+ const stale = usePlayerStore.getState().timelineFocus;
517
+ if (!stale) throw new Error("expected timeline focus request");
518
+ store.requestTimelineFocus("clip:el-2");
519
+ const replacement = usePlayerStore.getState().timelineFocus;
520
+ if (!replacement) throw new Error("expected replacement timeline focus request");
521
+ store.clearTimelineFocus(stale.nonce);
522
+ expect(usePlayerStore.getState().timelineFocus).toBe(replacement);
523
+ store.beginTimelineSession("project-b");
524
+ expect(usePlayerStore.getState().timelineFocus).toBeNull();
442
525
  });
443
526
  });
444
527