@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
@@ -0,0 +1,161 @@
1
+ // @vitest-environment happy-dom
2
+ import { act, createElement } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import type { TimelineElement } from "../store/playerStore";
6
+ import { usePlayerStore } from "../store/playerStore";
7
+ import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu";
8
+ import { TimelineOverlays, resolveTimelineContextElement } from "./TimelineOverlays";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
+
11
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
+
13
+ const roots: Root[] = [];
14
+ afterEach(() => {
15
+ for (const root of roots.splice(0)) act(() => root.unmount());
16
+ document.body.innerHTML = "";
17
+ usePlayerStore.setState({ selectedElementId: null, timelineSessionEpoch: 0 });
18
+ });
19
+
20
+ const captured: TimelineElement = {
21
+ id: "child",
22
+ key: "parent::child",
23
+ tag: "div",
24
+ start: 1,
25
+ duration: 2,
26
+ track: 3,
27
+ };
28
+
29
+ describe("resolveTimelineContextElement", () => {
30
+ it("returns the current expanded model instead of the captured snapshot", () => {
31
+ const current = { ...captured, start: 4, track: 7 };
32
+
33
+ expect(
34
+ resolveTimelineContextElement({
35
+ capturedElement: captured,
36
+ targetSessionEpoch: 2,
37
+ sessionEpoch: 2,
38
+ selectedElementId: "parent::child",
39
+ elements: [current],
40
+ }),
41
+ ).toBe(current);
42
+ });
43
+
44
+ it("resolves synthetic expanded children that are absent from raw store elements", () => {
45
+ expect(
46
+ resolveTimelineContextElement({
47
+ capturedElement: captured,
48
+ targetSessionEpoch: 2,
49
+ sessionEpoch: 2,
50
+ selectedElementId: "parent::child",
51
+ elements: [captured],
52
+ }),
53
+ ).toBe(captured);
54
+ });
55
+
56
+ it("rejects stale sessions, changed selection, and removed elements", () => {
57
+ const input = {
58
+ capturedElement: captured,
59
+ targetSessionEpoch: 2,
60
+ sessionEpoch: 2,
61
+ selectedElementId: "parent::child",
62
+ elements: [captured],
63
+ };
64
+
65
+ expect(resolveTimelineContextElement({ ...input, sessionEpoch: 3 })).toBeNull();
66
+ expect(resolveTimelineContextElement({ ...input, selectedElementId: "other" })).toBeNull();
67
+ expect(resolveTimelineContextElement({ ...input, elements: [] })).toBeNull();
68
+ });
69
+ });
70
+
71
+ function renderKeyframeOverlay(options: {
72
+ capturedElement: TimelineElement;
73
+ currentElement: TimelineElement;
74
+ setKfContextMenu?: ReturnType<typeof vi.fn>;
75
+ onDeleteAllKeyframes?: ReturnType<typeof vi.fn>;
76
+ }) {
77
+ const container = document.createElement("div");
78
+ document.body.appendChild(container);
79
+ const root = createRoot(container);
80
+ roots.push(root);
81
+ const elements = [options.currentElement];
82
+ const setKfContextMenu = options.setKfContextMenu ?? vi.fn();
83
+ const onDeleteAllKeyframes = options.onDeleteAllKeyframes ?? vi.fn();
84
+ const menu: KeyframeDiamondContextMenuState = {
85
+ x: 10,
86
+ y: 10,
87
+ sessionEpoch: 2,
88
+ element: options.capturedElement,
89
+ elementId: options.capturedElement.key ?? options.capturedElement.id,
90
+ percentage: 50,
91
+ animationId: "child-position",
92
+ };
93
+
94
+ act(() => {
95
+ usePlayerStore.setState({
96
+ selectedElementId: options.capturedElement.key ?? options.capturedElement.id,
97
+ timelineSessionEpoch: 2,
98
+ });
99
+ root.render(
100
+ createElement(TimelineOverlays, {
101
+ elements,
102
+ elementsRef: { current: elements },
103
+ theme: defaultTimelineTheme,
104
+ showShortcutHint: false,
105
+ showPopover: false,
106
+ rangeSelection: null,
107
+ setShowPopover: vi.fn(),
108
+ setRangeSelection: vi.fn(),
109
+ kfContextMenu: menu,
110
+ setKfContextMenu,
111
+ onDeleteKeyframe: vi.fn(),
112
+ onDeleteAllKeyframes,
113
+ onMoveKeyframeToPlayhead: vi.fn(),
114
+ clipContextMenu: null,
115
+ setClipContextMenu: vi.fn(),
116
+ currentTime: 0,
117
+ onSplitElement: vi.fn(),
118
+ pinZoomBeforeEdit: vi.fn(),
119
+ onDeleteElement: vi.fn(),
120
+ gapContextMenu: null,
121
+ onDismissGapContextMenu: vi.fn(),
122
+ onCloseTrackGap: vi.fn(),
123
+ onCloseAllTrackGaps: vi.fn(),
124
+ onHoverGapAction: vi.fn(),
125
+ }),
126
+ );
127
+ });
128
+ return { setKfContextMenu, onDeleteAllKeyframes };
129
+ }
130
+
131
+ describe("TimelineOverlays context lifecycle", () => {
132
+ it("dismisses a keyframe menu when its selected target becomes stale", () => {
133
+ const setKfContextMenu = vi.fn();
134
+ renderKeyframeOverlay({
135
+ capturedElement: captured,
136
+ currentElement: captured,
137
+ setKfContextMenu,
138
+ });
139
+
140
+ act(() => usePlayerStore.setState({ selectedElementId: "other" }));
141
+
142
+ expect(setKfContextMenu).toHaveBeenCalledExactlyOnceWith(null);
143
+ });
144
+
145
+ it("dispatches a menu action with the current model element", () => {
146
+ const current = { ...captured, start: 4, track: 7 };
147
+ const onDeleteAllKeyframes = vi.fn();
148
+ renderKeyframeOverlay({
149
+ capturedElement: captured,
150
+ currentElement: current,
151
+ onDeleteAllKeyframes,
152
+ });
153
+ const button = Array.from(document.body.querySelectorAll("button")).find(
154
+ (candidate) => candidate.textContent === "Delete All Keyframes",
155
+ );
156
+
157
+ act(() => button?.click());
158
+
159
+ expect(onDeleteAllKeyframes).toHaveBeenCalledExactlyOnceWith(current, "child-position");
160
+ });
161
+ });
@@ -1,4 +1,6 @@
1
+ import { useEffect, type MutableRefObject } from "react";
1
2
  import type { TimelineElement } from "../store/playerStore";
3
+ import { usePlayerStore } from "../store/playerStore";
2
4
  import type { TimelineTheme } from "./timelineTheme";
3
5
  import type { TimelineRangeSelection } from "./timelineEditing";
4
6
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -11,10 +13,11 @@ import { ClipContextMenu } from "./ClipContextMenu";
11
13
  import { TrackGapContextMenu } from "./TrackGapContextMenu";
12
14
  import { TimelineShortcutHint } from "./TimelineShortcutHint";
13
15
 
14
- interface ClipContextMenuState {
16
+ export interface ClipContextMenuState {
15
17
  x: number;
16
18
  y: number;
17
19
  element: TimelineElement;
20
+ sessionEpoch: number;
18
21
  }
19
22
 
20
23
  /** Resolved model for the empty-lane-space (track gap) context menu. */
@@ -28,6 +31,8 @@ interface TrackGapContextMenuState {
28
31
  }
29
32
 
30
33
  interface TimelineOverlaysProps {
34
+ elements: readonly TimelineElement[];
35
+ elementsRef: MutableRefObject<readonly TimelineElement[]>;
31
36
  theme: TimelineTheme;
32
37
  showShortcutHint: boolean;
33
38
  showPopover: boolean;
@@ -52,10 +57,49 @@ interface TimelineOverlaysProps {
52
57
  onHoverGapAction: (action: "close-gap" | "close-all" | null) => void;
53
58
  }
54
59
 
60
+ interface TimelineContextTargetInput {
61
+ capturedElement: TimelineElement;
62
+ targetSessionEpoch: number | undefined;
63
+ sessionEpoch: number;
64
+ selectedElementId: string | null;
65
+ elements: readonly TimelineElement[];
66
+ }
67
+
68
+ /** The captured project session and current selection jointly own a context target. */
69
+ export function resolveTimelineContextElement({
70
+ capturedElement,
71
+ targetSessionEpoch,
72
+ sessionEpoch,
73
+ selectedElementId,
74
+ elements,
75
+ }: TimelineContextTargetInput): TimelineElement | null {
76
+ const identity = capturedElement.key ?? capturedElement.id;
77
+ if (targetSessionEpoch !== sessionEpoch) return null;
78
+ if (selectedElementId !== identity) return null;
79
+ return elements.find((element) => (element.key ?? element.id) === identity) ?? null;
80
+ }
81
+
82
+ function readTimelineContextElement(
83
+ capturedElement: TimelineElement,
84
+ targetSessionEpoch: number | undefined,
85
+ elements: readonly TimelineElement[],
86
+ ): TimelineElement | null {
87
+ const state = usePlayerStore.getState();
88
+ return resolveTimelineContextElement({
89
+ capturedElement,
90
+ targetSessionEpoch,
91
+ sessionEpoch: state.timelineSessionEpoch,
92
+ selectedElementId: state.selectedElementId,
93
+ elements,
94
+ });
95
+ }
96
+
55
97
  // The timeline's floating overlays, rendered as siblings above the scroll area:
56
98
  // the shortcut hint, the range-edit popover, the keyframe-diamond context menu,
57
99
  // and the clip context menu.
58
100
  export function TimelineOverlays({
101
+ elements,
102
+ elementsRef,
59
103
  theme,
60
104
  showShortcutHint,
61
105
  showPopover,
@@ -79,6 +123,39 @@ export function TimelineOverlays({
79
123
  onCloseAllTrackGaps,
80
124
  onHoverGapAction,
81
125
  }: TimelineOverlaysProps) {
126
+ const selectedElementId = usePlayerStore((state) => state.selectedElementId);
127
+ const sessionEpoch = usePlayerStore((state) => state.timelineSessionEpoch);
128
+ const kfTargetSessionEpoch = kfContextMenu?.sessionEpoch;
129
+ const clipTargetSessionEpoch = clipContextMenu?.sessionEpoch;
130
+ const keyframeElement = kfContextMenu
131
+ ? resolveTimelineContextElement({
132
+ capturedElement: kfContextMenu.element,
133
+ targetSessionEpoch: kfTargetSessionEpoch,
134
+ sessionEpoch,
135
+ selectedElementId,
136
+ elements,
137
+ })
138
+ : null;
139
+ const clipElement = clipContextMenu
140
+ ? resolveTimelineContextElement({
141
+ capturedElement: clipContextMenu.element,
142
+ targetSessionEpoch: clipTargetSessionEpoch,
143
+ sessionEpoch,
144
+ selectedElementId,
145
+ elements,
146
+ })
147
+ : null;
148
+ const readCurrentElement = (element: TimelineElement, targetSessionEpoch: number | undefined) =>
149
+ readTimelineContextElement(element, targetSessionEpoch, elementsRef.current);
150
+
151
+ useEffect(() => {
152
+ if (kfContextMenu && !keyframeElement) setKfContextMenu(null);
153
+ }, [keyframeElement, kfContextMenu, setKfContextMenu]);
154
+
155
+ useEffect(() => {
156
+ if (clipContextMenu && !clipElement) setClipContextMenu(null);
157
+ }, [clipContextMenu, clipElement, setClipContextMenu]);
158
+
82
159
  return (
83
160
  <>
84
161
  {showShortcutHint && !showPopover && !rangeSelection && (
@@ -98,29 +175,45 @@ export function TimelineOverlays({
98
175
  />
99
176
  )}
100
177
 
101
- {kfContextMenu && (
178
+ {kfContextMenu && keyframeElement && (
102
179
  <KeyframeDiamondContextMenu
103
- state={kfContextMenu}
180
+ state={{ ...kfContextMenu, element: keyframeElement }}
104
181
  onClose={() => setKfContextMenu(null)}
105
- onDelete={(elId, keyframe) => onDeleteKeyframe?.(elId, keyframe)}
106
- onDeleteAll={(element) => onDeleteAllKeyframes?.(element)}
182
+ onDelete={(...args) => {
183
+ if (!readCurrentElement(keyframeElement, kfTargetSessionEpoch)) return;
184
+ onDeleteKeyframe?.(...args);
185
+ }}
186
+ onDeleteAll={(_element, animationId) => {
187
+ const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
188
+ if (element) onDeleteAllKeyframes?.(element, animationId);
189
+ }}
107
190
  onMoveToPlayhead={
108
- onMoveKeyframeToPlayhead ? (...args) => onMoveKeyframeToPlayhead(...args) : undefined
191
+ onMoveKeyframeToPlayhead
192
+ ? (_element, ...args) => {
193
+ const element = readCurrentElement(keyframeElement, kfTargetSessionEpoch);
194
+ if (element) onMoveKeyframeToPlayhead(element, ...args);
195
+ }
196
+ : undefined
109
197
  }
110
198
  />
111
199
  )}
112
200
 
113
- {clipContextMenu && (
201
+ {clipContextMenu && clipElement && (
114
202
  <ClipContextMenu
115
203
  x={clipContextMenu.x}
116
204
  y={clipContextMenu.y}
117
- element={clipContextMenu.element}
205
+ element={clipElement}
118
206
  currentTime={currentTime}
119
207
  onClose={() => setClipContextMenu(null)}
120
- onSplit={(el, time) => onSplitElement?.(el, time)}
121
- onDelete={(el) => {
208
+ onSplit={(_element, time) => {
209
+ const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
210
+ if (element) onSplitElement?.(element, time);
211
+ }}
212
+ onDelete={() => {
213
+ const element = readCurrentElement(clipElement, clipTargetSessionEpoch);
214
+ if (!element) return;
122
215
  pinZoomBeforeEdit();
123
- onDeleteElement?.(el);
216
+ onDeleteElement?.(element);
124
217
  }}
125
218
  />
126
219
  )}
@@ -70,6 +70,7 @@ function renderPropertyLanes(overrides: Partial<TimelinePropertyLanesProps> = {}
70
70
  act(() => {
71
71
  root.render(
72
72
  <TimelinePropertyLanes
73
+ id="timeline-property-lanes-test"
73
74
  animations={[]}
74
75
  clipStart={0}
75
76
  clipDuration={1}
@@ -367,6 +368,7 @@ describe("TimelinePropertyLanes", () => {
367
368
  act(() => {
368
369
  root.render(
369
370
  <TimelinePropertyLanes
371
+ id="timeline-property-lanes-selected-test"
370
372
  animations={animations}
371
373
  clipStart={0}
372
374
  clipDuration={1}
@@ -417,6 +419,7 @@ describe("TimelinePropertyLanes", () => {
417
419
  act(() => {
418
420
  root.render(
419
421
  <TimelinePropertyLanes
422
+ id="timeline-property-lanes-unselected-test"
420
423
  animations={animations}
421
424
  clipStart={0}
422
425
  clipDuration={1}
@@ -433,7 +436,7 @@ describe("TimelinePropertyLanes", () => {
433
436
  });
434
437
  const unselectedSegments = laneEaseSegments(host, "position");
435
438
  expect(unselectedSegments).toHaveLength(2);
436
- expect(revealEaseButton(unselectedSegments[0]!)).not.toBeNull();
439
+ expect(laneEaseButtons(host, "position")).toHaveLength(2);
437
440
  act(() => root.unmount());
438
441
  });
439
442
 
@@ -461,13 +464,14 @@ describe("TimelinePropertyLanes", () => {
461
464
  expect(new Set(paths).size).toBe(3);
462
465
  // Uniqueness alone passes even when the curves are swapped between segments.
463
466
  // Each segment is labelled with the ease it draws, so pin the ORDER: a
464
- // segment carries the ease of the keyframe it arrives at.
467
+ // segment carries the ease of the keyframe it arrives at. The trailing time
468
+ // is what separates two segments that share an ease name in the same lane.
465
469
  expect(
466
470
  segments.map((segment) => revealEaseButton(segment)?.getAttribute("aria-label")),
467
471
  ).toEqual([
468
- "Edit none easing",
469
- "Edit power2.out easing",
470
- "Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing",
472
+ "Edit none easing after 0s",
473
+ "Edit power2.out easing after 0.33s",
474
+ "Edit custom(M0,0 C0.1,0.2 0.3,0.9 1,1) easing after 0.66s",
471
475
  ]);
472
476
  act(() => root.unmount());
473
477
  });
@@ -9,6 +9,7 @@ import { synthesizeFlatTweenKeyframes } from "../../hooks/gsapTweenSynth";
9
9
  import { TimelineDiamondLane, type TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
10
10
  import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
11
11
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
12
+ import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
12
13
 
13
14
  export interface TimelinePropertyLanesProps {
14
15
  /**
@@ -16,7 +17,7 @@ export interface TimelinePropertyLanesProps {
16
17
  * `aria-controls` at the lanes a sighted user sees it reveal. Minted by
17
18
  * TimelineLanes, which owns both this subtree and the caret's.
18
19
  */
19
- id?: string;
20
+ id: string;
20
21
  animations: readonly GsapAnimation[];
21
22
  clipStart: number;
22
23
  clipDuration: number;
@@ -27,6 +28,7 @@ export interface TimelinePropertyLanesProps {
27
28
  currentPercentage: number;
28
29
  elementId: string;
29
30
  selectedKeyframes: ReadonlySet<string>;
31
+ rovingTargetId?: string | null;
30
32
  onSelectSegment?: (target: TimelineKeyframeTarget) => void;
31
33
  onClickKeyframe?: (target: TimelineKeyframeTarget) => void;
32
34
  onShiftClickKeyframe?: (target: TimelineKeyframeTarget) => void;
@@ -187,6 +189,7 @@ export function TimelinePropertyLanes({
187
189
  currentPercentage,
188
190
  elementId,
189
191
  selectedKeyframes,
192
+ rovingTargetId = null,
190
193
  onSelectSegment,
191
194
  onClickKeyframe,
192
195
  onShiftClickKeyframe,
@@ -223,9 +226,11 @@ export function TimelinePropertyLanes({
223
226
  {laneData.map(({ group, keyframesData }, laneIndex) => (
224
227
  <div
225
228
  key={group}
229
+ id={timelineLogicalRowCellId(id, timelinePropertyRowId(elementId, group), "content")}
226
230
  role="group"
227
231
  aria-label={`${group} keyframes`}
228
232
  data-property-group={group}
233
+ data-timeline-element-id={elementId}
229
234
  data-timeline-property-lane=""
230
235
  data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
231
236
  className="absolute"
@@ -240,12 +245,14 @@ export function TimelinePropertyLanes({
240
245
  keyframesData={keyframesData}
241
246
  clipWidthPx={clipWidthPx}
242
247
  clipHeightPx={LANE_H}
243
- clipDuration={clipDuration}
244
248
  accentColor={accentColor}
245
249
  isSelected={isSelected}
246
250
  currentPercentage={currentPercentage}
247
251
  elementId={elementId}
252
+ clipStart={clipStart}
253
+ clipDuration={clipDuration}
248
254
  selectedKeyframes={selectedKeyframes}
255
+ rovingTargetId={rovingTargetId}
249
256
  onSelectSegment={onSelectSegment}
250
257
  onClickKeyframe={onClickKeyframe}
251
258
  onShiftClickKeyframe={onShiftClickKeyframe}
@@ -379,6 +379,7 @@ describe("TimelineTrackHeader", () => {
379
379
  act(() => {
380
380
  lanesRoot.render(
381
381
  <TimelinePropertyLanes
382
+ id="timeline-property-lanes-alignment-test"
382
383
  animations={animations}
383
384
  clipStart={0}
384
385
  clipDuration={2}
@@ -16,6 +16,7 @@ import {
16
16
  } from "./trackHeaderLaneState";
17
17
  import { valueReadout } from "./trackHeaderLaneValues";
18
18
  import { trackDisplaySuffix } from "./timelineTrackDisplay";
19
+ import { timelineLogicalRowCellId, timelinePropertyRowId } from "./timelineNavigationIdentity";
19
20
 
20
21
  interface TimelineTrackHeaderProps {
21
22
  /** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
@@ -41,6 +42,7 @@ interface TimelineTrackHeaderProps {
41
42
  currentTime: number;
42
43
  isTrackHidden: boolean;
43
44
  isAudioTrack: boolean;
45
+ rovingTargetId?: string | null;
44
46
  theme: TimelineTheme;
45
47
  onToggleClipExpanded: () => void;
46
48
  onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"];
@@ -191,6 +193,7 @@ function PropertyGroupNavigation({
191
193
  }
192
194
 
193
195
  function PropertyGroupHeaderRow({
196
+ lanesId,
194
197
  lane,
195
198
  laneIndex,
196
199
  isLastLane,
@@ -201,7 +204,9 @@ function PropertyGroupHeaderRow({
201
204
  columnWidth,
202
205
  onTogglePropertyGroupKeyframe,
203
206
  onSeek,
207
+ rovingTargetId = null,
204
208
  }: {
209
+ lanesId: string;
205
210
  lane: TimelinePropertyLane;
206
211
  laneIndex: number;
207
212
  isLastLane: boolean;
@@ -212,7 +217,9 @@ function PropertyGroupHeaderRow({
212
217
  columnWidth: number;
213
218
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
214
219
  onSeek?: (time: number) => void;
220
+ rovingTargetId: string | null;
215
221
  }) {
222
+ const elementId = expandedElement.key ?? expandedElement.id;
216
223
  const { navigation, values, label, toggleTarget } = resolveLaneHeaderState(
217
224
  lane,
218
225
  currentTime,
@@ -221,6 +228,10 @@ function PropertyGroupHeaderRow({
221
228
 
222
229
  return (
223
230
  <div
231
+ id={timelineLogicalRowCellId(lanesId, timelinePropertyRowId(elementId, lane.group), "header")}
232
+ data-timeline-focus-id={timelinePropertyRowId(elementId, lane.group)}
233
+ data-timeline-element-id={elementId}
234
+ tabIndex={rovingTargetId === timelinePropertyRowId(elementId, lane.group) ? 0 : -1}
224
235
  data-property-group={lane.group}
225
236
  data-timeline-lane-top={getTimelineLaneTop(laneIndex)}
226
237
  className="absolute left-0 flex items-center gap-1 overflow-hidden px-1.5 text-[10px] text-white/65"
@@ -298,6 +309,7 @@ export function TimelineTrackHeader({
298
309
  onToggleTrackHidden,
299
310
  onTogglePropertyGroupKeyframe,
300
311
  onSeek,
312
+ rovingTargetId = null,
301
313
  }: TimelineTrackHeaderProps) {
302
314
  const clipPercentage = keyframeClip
303
315
  ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
@@ -314,6 +326,8 @@ export function TimelineTrackHeader({
314
326
 
315
327
  return (
316
328
  <div
329
+ role="rowheader"
330
+ aria-colindex={1}
317
331
  className={`sticky left-0 z-[12] shrink-0 ${
318
332
  !isKeyframeLayer
319
333
  ? showTrackLabel
@@ -374,6 +388,7 @@ export function TimelineTrackHeader({
374
388
  lanes.map((lane, laneIndex) => (
375
389
  <PropertyGroupHeaderRow
376
390
  key={lane.group}
391
+ lanesId={lanesId}
377
392
  lane={lane}
378
393
  laneIndex={laneIndex}
379
394
  isLastLane={laneIndex === lanes.length - 1}
@@ -384,6 +399,7 @@ export function TimelineTrackHeader({
384
399
  columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
385
400
  onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
386
401
  onSeek={onSeek}
402
+ rovingTargetId={rovingTargetId}
387
403
  />
388
404
  ))}
389
405
  </>
@@ -1,14 +1,19 @@
1
1
  import type { ReactNode } from "react";
2
+ import { timelineLogicalRowCellId } from "./timelineNavigationIdentity";
3
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
2
4
 
3
5
  interface TimelineTrackRowProps {
4
6
  index: number;
5
7
  rowKey: number;
6
- rowCount: number;
8
+ logicalRow: TimelineLogicalRow;
9
+ propertyRows: readonly TimelineLogicalRow[];
10
+ lanesId: string;
7
11
  top: number;
8
12
  height: number;
9
13
  virtualized: boolean;
10
14
  background: string;
11
15
  borderColor: string;
16
+ rovingTargetId?: string | null;
12
17
  children: ReactNode;
13
18
  }
14
19
 
@@ -16,23 +21,24 @@ interface TimelineTrackRowProps {
16
21
  export function TimelineTrackRow({
17
22
  index,
18
23
  rowKey,
19
- rowCount,
24
+ logicalRow,
25
+ propertyRows,
26
+ lanesId,
20
27
  top,
21
28
  height,
22
29
  virtualized,
23
30
  background,
24
31
  borderColor,
32
+ rovingTargetId = null,
25
33
  children,
26
34
  }: TimelineTrackRowProps) {
27
35
  return (
28
36
  <div
29
- role="listitem"
30
- aria-posinset={index + 1}
31
- aria-setsize={rowCount}
37
+ role="rowgroup"
32
38
  data-index={index}
33
39
  data-timeline-row={index}
34
40
  data-timeline-row-key={rowKey}
35
- className={`${virtualized ? "absolute left-0 right-0" : "relative"} flex`}
41
+ className={virtualized ? "absolute left-0 right-0" : "relative"}
36
42
  style={{
37
43
  top: virtualized ? top : undefined,
38
44
  height,
@@ -40,7 +46,52 @@ export function TimelineTrackRow({
40
46
  borderBottom: `1px solid ${borderColor}`,
41
47
  }}
42
48
  >
43
- {children}
49
+ <div
50
+ role="row"
51
+ aria-rowindex={logicalRow.logicalIndex + 1}
52
+ aria-level={logicalRow.level}
53
+ aria-expanded={logicalRow.expandable ? logicalRow.expanded : undefined}
54
+ data-timeline-logical-row-id={logicalRow.id}
55
+ data-timeline-focus-id={logicalRow.id}
56
+ tabIndex={rovingTargetId === logicalRow.id ? 0 : -1}
57
+ className="flex"
58
+ style={{ height }}
59
+ >
60
+ {children}
61
+ </div>
62
+ {propertyRows.map((row) => {
63
+ const group = row.propertyGroup;
64
+ const keyframeCount = row.items.filter((item) => item.kind === "keyframe").length;
65
+ const easeCount = row.items.filter((item) => item.kind === "ease").length;
66
+ return (
67
+ // ponytail: aria-owns maps this hidden logical row onto the two visible
68
+ // property-lane cells without duplicating interactive controls.
69
+ <div
70
+ key={row.id}
71
+ role="row"
72
+ aria-rowindex={row.logicalIndex + 1}
73
+ aria-level={row.level}
74
+ data-property-group={group}
75
+ data-timeline-logical-row-id={row.id}
76
+ className="sr-only"
77
+ >
78
+ <div
79
+ role="rowheader"
80
+ aria-colindex={1}
81
+ aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
82
+ >
83
+ {group}
84
+ </div>
85
+ <div
86
+ role="gridcell"
87
+ aria-colindex={2}
88
+ aria-owns={timelineLogicalRowCellId(lanesId, row.id, "content")}
89
+ >
90
+ {keyframeCount} keyframes, {easeCount} ease controls
91
+ </div>
92
+ </div>
93
+ );
94
+ })}
44
95
  </div>
45
96
  );
46
97
  }
@@ -73,7 +73,7 @@ export interface TimelineEditCallbacks {
73
73
  onRazorSplit?: (element: TimelineElement, splitTime: number) => Promise<void> | void;
74
74
  onRazorSplitAll?: (splitTime: number) => Promise<void> | void;
75
75
  onDeleteKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
76
- onDeleteAllKeyframes?: (element: TimelineElement) => void;
76
+ onDeleteAllKeyframes?: (element: TimelineElement, animationId?: string) => void;
77
77
  onMoveKeyframeToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void;
78
78
  /** Drag-to-retime: `keyframe` identifies the dragged keyframe (its percentage
79
79
  * is clip-relative), `toClipPercentage` is the neighbour-clamped drop. */