@hyperframes/studio 0.7.92 → 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 (108) hide show
  1. package/dist/assets/{hyperframes-player-9qOa08Tv.js → hyperframes-player-NSdcoOcK.js} +1 -1
  2. package/dist/assets/{index-CDTN2ZVa.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-Bw4lNdtO.js → index-wS6f7F5q.js} +1 -1
  6. package/dist/index.d.ts +20 -20
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7055 -5321
  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/editor/propertyPanel3dTransform.tsx +1 -1
  16. package/src/components/editor/propertyPanelCommitField.tsx +37 -2
  17. package/src/components/editor/propertyPanelFlatPrimitives.test.tsx +69 -0
  18. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -2
  19. package/src/components/editor/propertyPanelPrimitives.tsx +2 -2
  20. package/src/components/editor/propertyPanelTransformCommit.test.ts +57 -0
  21. package/src/components/editor/propertyPanelTransformCommit.ts +14 -14
  22. package/src/components/editor/propertyPanelTypes.ts +9 -3
  23. package/src/components/editor/useInspectorGestureTransaction.test.tsx +65 -0
  24. package/src/components/editor/useInspectorGestureTransaction.ts +66 -10
  25. package/src/components/sidebar/AssetCard.tsx +4 -3
  26. package/src/components/sidebar/AudioRow.tsx +4 -3
  27. package/src/contexts/FileManagerContext.tsx +12 -0
  28. package/src/hooks/externalFileChangeOwnership.test.ts +17 -0
  29. package/src/hooks/externalFileReloadBus.test.ts +34 -0
  30. package/src/hooks/externalFileReloadBus.ts +18 -0
  31. package/src/hooks/gsapDragCommit.test.ts +39 -1
  32. package/src/hooks/gsapDragCommit.ts +12 -40
  33. package/src/hooks/gsapEditOutcome.ts +72 -0
  34. package/src/hooks/gsapResizeIntercept.test.ts +90 -2
  35. package/src/hooks/gsapResizeIntercept.ts +44 -16
  36. package/src/hooks/gsapRuntimeBridge.test.ts +195 -27
  37. package/src/hooks/gsapRuntimeBridge.ts +93 -28
  38. package/src/hooks/useAnimatedPropertyCommit.test.tsx +194 -24
  39. package/src/hooks/useAnimatedPropertyCommit.ts +101 -49
  40. package/src/hooks/useAppHotkeys.textEditing.test.tsx +215 -0
  41. package/src/hooks/useDomGeometryCommits.test.tsx +61 -0
  42. package/src/hooks/useDomGeometryCommits.ts +20 -0
  43. package/src/hooks/useEditorSave.test.tsx +131 -0
  44. package/src/hooks/useEditorSave.ts +113 -39
  45. package/src/hooks/useExternalFileChangeCoordinator.test.tsx +221 -0
  46. package/src/hooks/useExternalFileChangeCoordinator.ts +429 -0
  47. package/src/hooks/useFileManager.projectOwnership.test.tsx +139 -7
  48. package/src/hooks/useFileManager.ts +32 -3
  49. package/src/hooks/useGsapAnimationFetchFallback.ts +45 -24
  50. package/src/hooks/useGsapAwareEditing.test.tsx +125 -39
  51. package/src/hooks/useGsapAwareEditing.ts +88 -27
  52. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +42 -0
  53. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -1
  54. package/src/hooks/usePreviewPersistence.test.ts +23 -0
  55. package/src/hooks/usePreviewPersistence.ts +22 -58
  56. package/src/hooks/useSdkSession.ts +8 -52
  57. package/src/hooks/useStudioExternalFileChanges.ts +81 -0
  58. package/src/hooks/useStudioSdkSessions.ts +2 -7
  59. package/src/hooks/useStudioSessionStart.ts +15 -0
  60. package/src/hooks/useStudioTestHooks.test.tsx +11 -7
  61. package/src/player/components/LayerDisclosureRow.tsx +2 -0
  62. package/src/player/components/Timeline.test.ts +24 -5
  63. package/src/player/components/Timeline.tsx +40 -40
  64. package/src/player/components/Timeline.virtualization.test.tsx +54 -11
  65. package/src/player/components/TimelineClip.test.tsx +17 -2
  66. package/src/player/components/TimelineClip.tsx +15 -3
  67. package/src/player/components/TimelineClipDiamonds.test.tsx +95 -10
  68. package/src/player/components/TimelineClipDiamonds.tsx +53 -13
  69. package/src/player/components/TimelineCompactDiamonds.tsx +84 -0
  70. package/src/player/components/TimelineDiamondConnectors.tsx +31 -3
  71. package/src/player/components/TimelineLanes.test.tsx +64 -6
  72. package/src/player/components/TimelineLanes.tsx +81 -64
  73. package/src/player/components/TimelinePropertyLanes.test.tsx +9 -5
  74. package/src/player/components/TimelinePropertyLanes.tsx +9 -2
  75. package/src/player/components/TimelineTrackHeader.test.tsx +1 -0
  76. package/src/player/components/TimelineTrackHeader.tsx +16 -0
  77. package/src/player/components/TimelineTrackRow.tsx +58 -7
  78. package/src/player/components/timelineDiamondTypes.ts +14 -7
  79. package/src/player/components/timelineKeyboardNavigation.test.ts +303 -0
  80. package/src/player/components/timelineKeyboardNavigation.ts +337 -0
  81. package/src/player/components/timelineLaneProps.ts +3 -0
  82. package/src/player/components/timelineNavigationIdentity.ts +38 -0
  83. package/src/player/components/useTimelineClipRenderWindow.ts +4 -35
  84. package/src/player/components/useTimelineFocusCoordinator.test.tsx +174 -0
  85. package/src/player/components/useTimelineFocusCoordinator.ts +221 -0
  86. package/src/player/components/useTimelineKeyboardActor.test.tsx +267 -0
  87. package/src/player/components/useTimelineKeyboardActor.ts +147 -0
  88. package/src/player/components/useTimelineLogicalFocus.ts +80 -0
  89. package/src/player/components/useTimelineLogicalRows.test.tsx +57 -0
  90. package/src/player/components/useTimelineLogicalRows.ts +47 -0
  91. package/src/player/components/useTimelineRowVirtualization.ts +16 -26
  92. package/src/player/store/liveTime.ts +11 -0
  93. package/src/player/store/playerStore.test.ts +29 -22
  94. package/src/player/store/playerStore.ts +24 -29
  95. package/src/player/store/timelineFocusState.ts +15 -0
  96. package/src/utils/domEditSaveQueue.test.ts +37 -1
  97. package/src/utils/domEditSaveQueue.ts +22 -4
  98. package/src/utils/externalConflictStorage.test.ts +95 -0
  99. package/src/utils/externalConflictStorage.ts +172 -0
  100. package/src/utils/studioFileVersion.test.ts +28 -1
  101. package/src/utils/studioFileVersion.ts +30 -0
  102. package/src/utils/studioPendingEdits.test.ts +108 -1
  103. package/src/utils/studioPendingEdits.ts +39 -2
  104. package/src/utils/studioSaveDiagnostics.ts +5 -0
  105. package/dist/assets/index-BXkzp_OU.css +0 -1
  106. package/dist/assets/index-DIntkrQl.js +0 -428
  107. package/src/player/components/useTimelineRevealClip.test.tsx +0 -139
  108. package/src/player/components/useTimelineRevealClip.ts +0 -180
@@ -59,11 +59,15 @@ describe("timeline performance fixture", () => {
59
59
  expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
60
60
  });
61
61
 
62
- it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
63
- const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
64
- const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
65
- expect(second).toEqual(first);
66
- });
62
+ it.each(PROFILES)(
63
+ "generates an identical 50k %s fixture",
64
+ (profile) => {
65
+ const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
66
+ const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
67
+ expect(second).toEqual(first);
68
+ },
69
+ 30_000,
70
+ );
67
71
 
68
72
  it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
69
73
  const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
@@ -90,7 +94,7 @@ describe("timeline performance fixture", () => {
90
94
  usePlayerStore.setState({
91
95
  isPlaying: true,
92
96
  requestedSeekTime: 42,
93
- clipRevealRequest: { elementId: "stale", nonce: 7 },
97
+ timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
94
98
  clipManifest: [],
95
99
  lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
96
100
  });
@@ -108,7 +112,7 @@ describe("timeline performance fixture", () => {
108
112
  expect(usePlayerStore.getState()).toMatchObject({
109
113
  isPlaying: false,
110
114
  requestedSeekTime: null,
111
- clipRevealRequest: null,
115
+ timelineFocus: null,
112
116
  clipManifest: null,
113
117
  duration: 600,
114
118
  timelineReady: true,
@@ -44,6 +44,8 @@ export function LayerDisclosureRow({
44
44
  >
45
45
  <button
46
46
  type="button"
47
+ // ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
48
+ tabIndex={-1}
47
49
  aria-expanded={isExpanded}
48
50
  aria-controls={lanesId}
49
51
  aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
@@ -244,6 +244,25 @@ describe("Timeline provider boundary", () => {
244
244
  expect(propertyLane.style.background).toBe("");
245
245
  expect(propertyLane.style.border).toBe("");
246
246
  expect(propertyLane.style.borderRadius).toBe("");
247
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
248
+ const semanticRows = treegrid?.querySelectorAll<HTMLElement>('[role="row"]') ?? [];
249
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("3");
250
+ expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([
251
+ "1",
252
+ "2",
253
+ "3",
254
+ ]);
255
+ expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1");
256
+ expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true");
257
+ expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2");
258
+ expect(semanticRows[1]?.textContent).toContain("position");
259
+ expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe(
260
+ headerLane.id,
261
+ );
262
+ expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe(
263
+ propertyLane.id,
264
+ );
265
+ expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
247
266
  expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
248
267
  expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
249
268
  expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
@@ -295,12 +314,12 @@ describe("Timeline provider boundary", () => {
295
314
  const root = createRoot(host);
296
315
  act(() => root.render(React.createElement(Timeline)));
297
316
 
298
- const list = host.querySelector<HTMLElement>('[role="list"]');
299
- const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
317
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
318
+ const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
300
319
  expect(rows).toHaveLength(12);
301
- expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
302
- expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
303
- expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
320
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("12");
321
+ expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
322
+ expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12");
304
323
 
305
324
  act(() => root.unmount());
306
325
  });
@@ -41,10 +41,10 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
41
41
  import { useTimelineTicks } from "./useTimelineTicks";
42
42
  import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
43
43
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
44
- import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
45
44
  import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
46
45
  import { useTimelineActiveClips } from "./useTimelineActiveClips";
47
46
  import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
47
+ import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
48
48
 
49
49
  export {
50
50
  shouldAutoScrollTimeline,
@@ -122,7 +122,6 @@ export const Timeline = memo(function Timeline({
122
122
  const timelineReady = usePlayerStore((s) => s.timelineReady);
123
123
  const selectedElementId = usePlayerStore((s) => s.selectedElementId);
124
124
  const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
125
- const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
126
125
  const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
127
126
  const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
128
127
  const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
@@ -171,7 +170,6 @@ export const Timeline = memo(function Timeline({
171
170
  const ppsRef = useRef(100);
172
171
  const durationRef = useRef(effectiveDuration);
173
172
  durationRef.current = effectiveDuration;
174
- // Declared before the fitPps derivation so the edit-pin wrappers can close over it.
175
173
  const fitPpsRef = useRef(100);
176
174
  const {
177
175
  pinZoomBeforeEdit,
@@ -269,14 +267,36 @@ export const Timeline = memo(function Timeline({
269
267
  expandedElements.length,
270
268
  displayLayout.totalH,
271
269
  ]);
272
- const rowWindow = useTimelineRowVirtualization({
270
+ const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
271
+ useTimelineGeometry({
272
+ viewportWidth: viewport.clientWidth,
273
+ effectiveDuration,
274
+ zoomMode,
275
+ manualZoomPercent,
276
+ ppsRef,
277
+ fitPpsRef,
278
+ draggedClip,
279
+ resizingClip,
280
+ expandedElements,
281
+ isDragging,
282
+ scrollRef,
283
+ lastScrollLeftRef,
284
+ contentOrigin,
285
+ });
286
+ const timelineFocus = useTimelineLogicalFocus({
273
287
  scrollRef,
288
+ tracks,
289
+ layout: displayLayout,
290
+ laneCounts,
291
+ selectedElementId,
292
+ selectedElementIds,
293
+ gsapAnimations,
294
+ elements: expandedElements,
295
+ pixelsPerSecond: pps,
296
+ contentOrigin,
297
+ allowHorizontal: zoomMode === "manual",
274
298
  viewport,
275
- rowGeometry: displayLayout.rowGeometry,
276
299
  sessionEpoch,
277
- elements: expandedElements,
278
- selectedElementId,
279
- revealElementId: clipRevealRequest?.elementId ?? null,
280
300
  draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
281
301
  resizingElementIds,
282
302
  clipContextMenuRowKey: clipContextMenu?.element.track,
@@ -284,7 +304,6 @@ export const Timeline = memo(function Timeline({
284
304
  lastScrollLeftRef,
285
305
  syncScrollViewport,
286
306
  });
287
- const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
288
307
  const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
289
308
  const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
290
309
  const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
@@ -297,22 +316,7 @@ export const Timeline = memo(function Timeline({
297
316
  setKfContextMenu,
298
317
  toggleSelectedKeyframe,
299
318
  });
300
- const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
301
- useTimelineGeometry({
302
- viewportWidth: viewport.clientWidth,
303
- effectiveDuration,
304
- zoomMode,
305
- manualZoomPercent,
306
- ppsRef,
307
- fitPpsRef,
308
- draggedClip,
309
- resizingClip,
310
- expandedElements,
311
- isDragging,
312
- scrollRef,
313
- lastScrollLeftRef,
314
- contentOrigin,
315
- });
319
+
316
320
  const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
317
321
  tracks,
318
322
  viewport,
@@ -322,7 +326,7 @@ export const Timeline = memo(function Timeline({
322
326
  selectedElementId: selectedElementId ?? undefined,
323
327
  draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
324
328
  resizingElementIds,
325
- revealElementId: clipRevealRequest?.elementId,
329
+ focusedElementId: timelineFocus.pinnedElementId,
326
330
  focusedEaseElementId: focusedEaseSegment?.elementId,
327
331
  clipContextMenuElementId: clipContextMenu
328
332
  ? getTimelineElementIdentity(clipContextMenu.element)
@@ -330,13 +334,6 @@ export const Timeline = memo(function Timeline({
330
334
  keyframeContextMenuElementId: kfContextMenu
331
335
  ? getTimelineElementIdentity(kfContextMenu.element)
332
336
  : undefined,
333
- focusedElementId,
334
- scrollRef,
335
- elements: expandedElements,
336
- rowGeometry: displayLayout.rowGeometry,
337
- allowHorizontalReveal: zoomMode === "manual",
338
- rowVirtualizationActive,
339
- sessionEpoch,
340
337
  });
341
338
  useTimelineActiveClips({
342
339
  scrollRef,
@@ -423,7 +420,7 @@ export const Timeline = memo(function Timeline({
423
420
  displayDuration,
424
421
  pps,
425
422
  timeDisplayMode,
426
- rowVirtualizationActive ? renderTimeRange : undefined,
423
+ timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
427
424
  );
428
425
 
429
426
  const getPreviewElement = useCallback(
@@ -469,13 +466,12 @@ export const Timeline = memo(function Timeline({
469
466
  recordTimelineScroll(e.currentTarget);
470
467
  syncScrollViewport(e.currentTarget, true);
471
468
  }}
472
- {...rowWindow.timelineFocusProps}
469
+ {...timelineFocus.timelineFocusProps}
473
470
  onDragOver={assetDrop.handleAssetDragOver}
474
471
  onDragLeave={assetDrop.handleAssetDragLeave}
475
472
  onDrop={assetDrop.handleAssetDrop}
476
473
  onPointerDown={(e) => {
477
- // Let interactive controls (keyframe nav/toggle, caret, inputs) handle
478
- // their own clicks — scrubbing here would preventDefault and eat them.
474
+ // Interactive controls own their clicks; scrubbing would preventDefault and eat them.
479
475
  if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
480
476
  if (splitAllAtPointer(e)) return;
481
477
  handlePointerDown(e);
@@ -502,8 +498,10 @@ export const Timeline = memo(function Timeline({
502
498
  displayTrackOrder={displayLayout.displayTrackOrder}
503
499
  rowHeights={displayLayout.displayRowHeights}
504
500
  rowGeometry={displayLayout.rowGeometry}
505
- virtualRows={virtualRows}
506
- rowsVirtualized={rowVirtualizationActive}
501
+ virtualRows={timelineFocus.virtualRows}
502
+ logicalRows={timelineFocus.logicalRows}
503
+ focusedTargetId={timelineFocus.focusedTargetId}
504
+ rowsVirtualized={timelineFocus.rowVirtualizationActive}
507
505
  clipIndex={clipIndex}
508
506
  renderTimeRange={renderTimeRange}
509
507
  pinnedClipIdentities={pinnedClipIdentities}
@@ -522,7 +520,9 @@ export const Timeline = memo(function Timeline({
522
520
  scrollRef={scrollRef}
523
521
  // Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
524
522
  renderClipContent={
525
- rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
523
+ timelineFocus.rowVirtualizationActive && viewport.isScrolling
524
+ ? undefined
525
+ : renderClipContent
526
526
  }
527
527
  renderClipOverlay={renderClipOverlay}
528
528
  playheadRef={playheadRef}
@@ -132,7 +132,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
132
132
 
133
133
  const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
134
134
 
135
- const rows = host.querySelectorAll('[role="listitem"]');
135
+ const rows = host.querySelectorAll("[data-timeline-row]");
136
136
  expect(rows.length).toBeGreaterThan(0);
137
137
  expect(rows.length).toBeLessThanOrEqual(16);
138
138
 
@@ -201,16 +201,17 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
201
201
 
202
202
  await settleUntil(
203
203
  () =>
204
- (host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
205
- 0,
204
+ (host.querySelector('[role="treegrid"]')?.querySelectorAll('[role="row"]').length ?? 0) > 0,
206
205
  );
207
- const list = host.querySelector<HTMLElement>('[role="list"]');
208
- const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
206
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
207
+ const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
209
208
  expect(rows.length).toBeGreaterThan(0);
210
209
  expect(rows.length).toBeLessThanOrEqual(16);
211
- expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
212
- expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
213
- expect(list?.parentElement?.style.height).toBe(
210
+ expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
211
+ expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
212
+ expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
213
+ expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
214
+ expect(treegrid?.parentElement?.style.height).toBe(
214
215
  `${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
215
216
  );
216
217
 
@@ -226,7 +227,44 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
226
227
  scroller.dispatchEvent(new Event("scroll"));
227
228
  });
228
229
  }
229
- expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
230
+ expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
231
+ expect(document.activeElement).toBe(focusedControl);
232
+
233
+ act(() => root.unmount());
234
+ usePlayerStore.getState().reset();
235
+ });
236
+
237
+ it("keeps focus pinning active after the scroll viewport remounts", async () => {
238
+ const [{ Timeline }, { usePlayerStore }] = await Promise.all([
239
+ import("./Timeline"),
240
+ import("../store/playerStore"),
241
+ ]);
242
+ usePlayerStore.setState({
243
+ duration: 60,
244
+ timelineReady: true,
245
+ elements: clipsByTrack(1_000),
246
+ });
247
+
248
+ const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 9 }));
249
+ await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
250
+ const firstScroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
251
+
252
+ await act(async () => usePlayerStore.setState({ timelineReady: false }));
253
+ expect(host.querySelector("[data-timeline-scroll-viewport]")).toBeNull();
254
+ await act(async () => usePlayerStore.setState({ timelineReady: true }));
255
+ await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
256
+
257
+ const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
258
+ const firstRow = host.querySelector<HTMLElement>('[data-timeline-row-key="0"]');
259
+ const focusedControl = firstRow?.querySelector<HTMLButtonElement>("button");
260
+ expect(scroller).not.toBe(firstScroller);
261
+ expect(focusedControl).not.toBeNull();
262
+ act(() => focusedControl?.focus());
263
+ if (scroller) {
264
+ scroller.scrollTop = 500 * 48;
265
+ await dispatchScroll(scroller);
266
+ }
267
+ expect(host.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
230
268
  expect(document.activeElement).toBe(focusedControl);
231
269
 
232
270
  act(() => root.unmount());
@@ -242,6 +280,8 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
242
280
  usePlayerStore.setState({
243
281
  duration: 1_000,
244
282
  timelineReady: true,
283
+ timelineProjectId: "project-a",
284
+ timelineSessionEpoch: 4,
245
285
  zoomMode: "manual",
246
286
  manualZoomPercent: 2_000,
247
287
  selectedElementId: "clip-490",
@@ -286,10 +326,13 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
286
326
  expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
287
327
  expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
288
328
 
289
- await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
329
+ const { timelineClipFocusId } = await import("./timelineNavigationIdentity");
330
+ await act(async () =>
331
+ usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")),
332
+ );
290
333
  await advanceFrame();
291
334
  await act(async () => {});
292
- expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
335
+ expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
293
336
  const focusedClip = host.querySelector('[data-el-id="clip-300"]');
294
337
  expect(document.activeElement).toBe(focusedClip);
295
338
  await advanceFrame();
@@ -36,6 +36,7 @@ function renderClip({
36
36
  const host = document.createElement("div");
37
37
  document.body.append(host);
38
38
  const root = createRoot(host);
39
+ const onClick = vi.fn();
39
40
 
40
41
  act(() => {
41
42
  root.render(
@@ -50,7 +51,7 @@ function renderClip({
50
51
  isComposition={false}
51
52
  onHoverStart={vi.fn()}
52
53
  onHoverEnd={vi.fn()}
53
- onClick={vi.fn()}
54
+ onClick={onClick}
54
55
  onDoubleClick={vi.fn()}
55
56
  >
56
57
  <div data-custom-content="true" />
@@ -58,7 +59,7 @@ function renderClip({
58
59
  );
59
60
  });
60
61
 
61
- return { host, root };
62
+ return { host, onClick, root };
62
63
  }
63
64
 
64
65
  describe("TimelineClip", () => {
@@ -113,4 +114,18 @@ describe("TimelineClip", () => {
113
114
 
114
115
  act(() => root.unmount());
115
116
  });
117
+
118
+ it("is a roving native button with explicit selection semantics", () => {
119
+ const { host, onClick, root } = renderClip({
120
+ element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
121
+ isSelected: true,
122
+ });
123
+ const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
124
+ expect(clip.type).toBe("button");
125
+ expect(clip.tabIndex).toBe(-1);
126
+ expect(clip.getAttribute("aria-pressed")).toBe("true");
127
+ act(() => clip.click());
128
+ expect(onClick).toHaveBeenCalledOnce();
129
+ act(() => root.unmount());
130
+ });
116
131
  });
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
3
3
  import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
4
4
  import type { TimelineEditCapabilities } from "./timelineEditing";
5
5
  import { isAudioTimelineElement } from "../../utils/timelineInspector";
6
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
6
7
 
7
8
  interface TimelineClipProps {
8
9
  el: TimelineElement;
@@ -18,6 +19,7 @@ interface TimelineClipProps {
18
19
  capabilities: TimelineEditCapabilities;
19
20
  theme?: TimelineTheme;
20
21
  isComposition: boolean;
22
+ tabIndex?: 0 | -1;
21
23
  onHoverStart: () => void;
22
24
  onHoverEnd: () => void;
23
25
  onPointerDown?: (e: React.PointerEvent) => void;
@@ -43,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
43
45
  capabilities,
44
46
  theme = defaultTimelineTheme,
45
47
  isComposition,
48
+ tabIndex = -1,
46
49
  onHoverStart,
47
50
  onHoverEnd,
48
51
  onPointerDown,
@@ -82,19 +85,28 @@ export const TimelineClip = memo(function TimelineClip({
82
85
  zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
83
86
  // Regular cursor over clips (CapCut-style, user preference) — no grab hand.
84
87
  cursor: "default",
88
+ appearance: "none",
89
+ color: "inherit",
90
+ font: "inherit",
91
+ padding: 0,
92
+ textAlign: "left",
85
93
  transform: isDragging ? "translateY(-1px)" : undefined,
86
94
  };
87
95
 
88
96
  return (
89
- <div
97
+ <button
98
+ type="button"
90
99
  data-clip={isGestureActor ? undefined : "true"}
91
100
  data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
101
+ data-timeline-focus-id={isGestureActor ? undefined : timelineClipFocusId(el.key ?? el.id)}
92
102
  data-clip-start={el.start}
93
103
  data-clip-end={el.start + el.duration}
94
104
  data-clip-hidden={el.hidden ? "true" : undefined}
95
105
  data-active={isActive ? "" : undefined}
96
106
  aria-hidden={isGestureActor ? "true" : undefined}
97
- tabIndex={isGestureActor ? undefined : -1}
107
+ tabIndex={isGestureActor ? undefined : tabIndex}
108
+ aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
109
+ aria-pressed={isGestureActor ? undefined : isSelected}
98
110
  className={clipClassName}
99
111
  style={style}
100
112
  title={
@@ -176,6 +188,6 @@ export const TimelineClip = memo(function TimelineClip({
176
188
  </span>
177
189
  )}
178
190
  {children}
179
- </div>
191
+ </button>
180
192
  );
181
193
  });
@@ -46,7 +46,7 @@ function createTimelineHost() {
46
46
  return host;
47
47
  }
48
48
 
49
- function renderDiamonds(onClickKeyframe = vi.fn()) {
49
+ function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
50
50
  const host = createTimelineHost();
51
51
  const root = createRoot(host);
52
52
  act(() => {
@@ -61,19 +61,60 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
61
61
  }}
62
62
  clipWidthPx={200}
63
63
  clipHeightPx={48}
64
- clipDuration={10}
65
64
  accentColor="#4ba3d2"
66
65
  isSelected
67
66
  currentPercentage={0}
68
67
  elementId="clip-1"
68
+ clipStart={10}
69
+ clipDuration={10}
69
70
  selectedKeyframes={new Set()}
70
71
  onClickKeyframe={onClickKeyframe}
72
+ onShiftClickKeyframe={onShiftClickKeyframe}
71
73
  />,
72
74
  );
73
75
  });
74
- return { host, root, onClickKeyframe };
76
+ return { host, root, onClickKeyframe, onShiftClickKeyframe };
75
77
  }
76
78
 
79
+ it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
80
+ const host = createTimelineHost();
81
+ const root = createRoot(host);
82
+ act(() => {
83
+ root.render(
84
+ <TimelineDiamondLane
85
+ keyframesData={{
86
+ format: "percentage",
87
+ keyframes: [
88
+ { percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
89
+ { percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
90
+ { percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
91
+ { percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
92
+ ],
93
+ }}
94
+ clipWidthPx={200}
95
+ clipHeightPx={48}
96
+ accentColor="#4ba3d2"
97
+ isSelected
98
+ currentPercentage={5}
99
+ elementId="clip-1"
100
+ clipStart={10}
101
+ clipDuration={10}
102
+ selectedKeyframes={new Set()}
103
+ />,
104
+ );
105
+ });
106
+
107
+ const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
108
+ const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
109
+ expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
110
+ expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
111
+ expect(after?.style.left).toBe("201px");
112
+ expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
113
+ expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);
114
+
115
+ act(() => root.unmount());
116
+ });
117
+
77
118
  function renderRetimeLane(
78
119
  onMoveKeyframe = vi.fn().mockResolvedValue(true),
79
120
  strict = false,
@@ -261,6 +302,33 @@ describe("TimelineClipDiamonds", () => {
261
302
  act(() => root.unmount());
262
303
  });
263
304
 
305
+ it("gives keyframes time-based names and native keyboard selection semantics", () => {
306
+ const { host, root, onClickKeyframe } = renderDiamonds();
307
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
308
+ expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
309
+ expect(diamond.getAttribute("aria-pressed")).toBe("false");
310
+ act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
311
+ expect(onClickKeyframe).toHaveBeenCalledWith(
312
+ "clip-1",
313
+ expect.objectContaining({ percentage: 50 }),
314
+ );
315
+ act(() => root.unmount());
316
+ });
317
+
318
+ it("uses Shift+Space's native click for additive keyframe selection", () => {
319
+ const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
320
+ const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
321
+ act(() =>
322
+ diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
323
+ );
324
+ expect(onShiftClickKeyframe).toHaveBeenCalledWith(
325
+ "clip-1",
326
+ expect.objectContaining({ percentage: 50 }),
327
+ );
328
+ expect(onClickKeyframe).not.toHaveBeenCalled();
329
+ act(() => root.unmount());
330
+ });
331
+
264
332
  it("publishes retime previews after StrictMode effect replay", () => {
265
333
  const { diamond, host, root } = renderRetimeLane(undefined, true);
266
334
  const initialLeft = diamond.style.left;
@@ -1108,10 +1176,9 @@ describe("TimelineClipDiamonds", () => {
1108
1176
  // Regression: onClickKeyframe's state updates can re-render the diamond
1109
1177
  // button out from under the gesture before the browser auto-synthesizes the
1110
1178
  // "click" event that follows a button's pointerdown+pointerup. That orphaned
1111
- // click then bubbles to the ancestor clip's onClick, which toggles selection
1112
- // off whenever the clip is already selected the state a diamond click
1113
- // always happens in so every keyframe click immediately deselected its
1114
- // own clip. suppressClickRef lets that ancestor ignore the stray click.
1179
+ // click then bubbles to the ancestor clip's onClick. That stray click can
1180
+ // replace keyframe focus or collapse a marquee selection, so suppressClickRef
1181
+ // lets the ancestor ignore it.
1115
1182
  it("arms suppressClickRef synchronously on a keyframe click", () => {
1116
1183
  const suppressClickRef = { current: false };
1117
1184
  const host = createTimelineHost();
@@ -1150,6 +1217,7 @@ describe("TimelineClipDiamonds", () => {
1150
1217
  const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
1151
1218
  const host = createTimelineHost();
1152
1219
  const root = createRoot(host);
1220
+ const onSelectSegment = vi.fn();
1153
1221
  const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
1154
1222
  percentage,
1155
1223
  tweenPercentage: percentage,
@@ -1182,13 +1250,15 @@ describe("TimelineClipDiamonds", () => {
1182
1250
  isSelected
1183
1251
  currentPercentage={0}
1184
1252
  elementId="clip-1"
1253
+ clipStart={10}
1254
+ clipDuration={10}
1185
1255
  selectedKeyframes={new Set()}
1186
- onSelectSegment={vi.fn()}
1256
+ onSelectSegment={onSelectSegment}
1187
1257
  groupAware
1188
1258
  />,
1189
1259
  );
1190
1260
  });
1191
- return { host, root };
1261
+ return { host, onSelectSegment, root };
1192
1262
  };
1193
1263
 
1194
1264
  it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
@@ -1201,8 +1271,23 @@ describe("TimelineClipDiamonds", () => {
1201
1271
  });
1202
1272
 
1203
1273
  it("shows the inline ease button on single-animation merged segments", () => {
1204
- const { host, root } = renderSegmentLane(false);
1274
+ const { host, onSelectSegment, root } = renderSegmentLane(false);
1205
1275
  expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
1276
+ const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
1277
+ expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
1278
+ expect(ease.classList.contains("opacity-0")).toBe(true);
1279
+ act(() => ease.click());
1280
+ expect(onSelectSegment).toHaveBeenCalledOnce();
1281
+ expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
1282
+ act(() => root.unmount());
1283
+ });
1284
+
1285
+ it("ends connectors at the diamond boundaries", () => {
1286
+ const { host, root } = renderSegmentLane(false);
1287
+ const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");
1288
+
1289
+ expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
1290
+ expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
1206
1291
  act(() => root.unmount());
1207
1292
  });
1208
1293