@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
@@ -1,7 +1,7 @@
1
- import { Fragment, useId } from "react";
1
+ import { Fragment, useId, useMemo } from "react";
2
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
3
3
  import { TimelineClip } from "./TimelineClip";
4
- import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
4
+ import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
5
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
6
6
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
7
7
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
@@ -27,6 +27,9 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
27
27
  import { isTimelineClipActive } from "./useTimelineActiveClips";
28
28
  import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
29
29
  import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
30
+ import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
31
+ import { timelineClipFocusId } from "./timelineNavigationIdentity";
32
+ import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
30
33
 
31
34
  interface TimelineLanesProps extends TimelineLaneBaseProps {
32
35
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -49,6 +52,8 @@ export function TimelineLanes({
49
52
  displayTrackOrder,
50
53
  rowGeometry,
51
54
  virtualRows,
55
+ logicalRows,
56
+ focusedTargetId,
52
57
  rowsVirtualized,
53
58
  clipIndex,
54
59
  renderTimeRange,
@@ -99,14 +104,20 @@ export function TimelineLanes({
99
104
  onRazorSplit,
100
105
  onRazorSplitAll,
101
106
  }: TimelineLanesProps) {
102
- // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
103
- // beside the main one) cannot both mint `...-track-0` and have every caret's
104
- // aria-controls resolve to whichever mounted first. React's useId embeds
105
- // colons, which are legal in an id and in aria-controls but need escaping in
106
- // a CSS `#id` selector, so they come out here and the prefix stays plain.
107
+ // ponytail: One per-instance namespace prevents aria-controls and aria-owns
108
+ // from resolving into a second timeline that renders the same logical rows.
107
109
  const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
108
110
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
109
111
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
112
+ const logicalRowsByTrack = useMemo(() => {
113
+ const byTrack = new Map<number, TimelineLogicalRow[]>();
114
+ for (const logicalRow of logicalRows) {
115
+ const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
116
+ trackRows.push(logicalRow);
117
+ byTrack.set(logicalRow.physicalTrackKey, trackRows);
118
+ }
119
+ return byTrack;
120
+ }, [logicalRows]);
110
121
  const toggleClipExpandedTracked = (key: string) => {
111
122
  const willExpand = !expandedClipIds.has(key);
112
123
  trackStudioKeyframeLaneExpand({ expanded: willExpand });
@@ -128,10 +139,23 @@ export function TimelineLanes({
128
139
  },
129
140
  ]
130
141
  : [];
142
+ const keyboard = useTimelineKeyboardActor({
143
+ logicalRows,
144
+ focusedTargetId,
145
+ rowGeometry,
146
+ scrollRef,
147
+ onToggleRow: (row) => {
148
+ if (row.elementId) toggleClipExpandedTracked(row.elementId);
149
+ },
150
+ });
131
151
  return (
132
152
  <div
133
- role="list"
153
+ role="treegrid"
134
154
  aria-label="Timeline tracks"
155
+ aria-rowcount={logicalRows.length}
156
+ aria-colcount={2}
157
+ onFocus={keyboard.onFocus}
158
+ onKeyDown={keyboard.onKeyDown}
135
159
  className={rowsVirtualized ? "absolute inset-0" : undefined}
136
160
  >
137
161
  {
@@ -140,6 +164,9 @@ export function TimelineLanes({
140
164
  const trackNum = displayTrackOrder[row];
141
165
  if (trackNum === undefined) return null;
142
166
  const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
167
+ const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
168
+ const logicalRow = trackLogicalRows[0];
169
+ if (!logicalRow) return null;
143
170
  const rowHeight = rowGeometry.getRowHeight(row);
144
171
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
145
172
  const renderElements = rowsVirtualized
@@ -156,9 +183,7 @@ export function TimelineLanes({
156
183
  draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
157
184
  // All lanes use the same uniform color — no alternating stripes.
158
185
  const rowBackground = theme.rowBackground;
159
- // The beat-dot strip occupies the top of this track's lane (active track,
160
- // or the music track when nothing is selected). When shown, keyframe
161
- // diamonds shrink + drop to the bottom half so they don't collide with it.
186
+ // Keep diamonds below the beat strip on the active/music track.
162
187
  const beatStripOnTrack =
163
188
  (beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
164
189
  (selectedElementId
@@ -166,9 +191,7 @@ export function TimelineLanes({
166
191
  : els.some(isMusicTrack));
167
192
  const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
168
193
  const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
169
- // The one keyframed element this track shows lanes for (selected, else
170
- // most lanes). A track can hold several elements; scoping to one keeps
171
- // their keyframes from cramming into a single row.
194
+ // Only the selected/most-keyframed clip owns expanded lanes on a shared track.
172
195
  const keyframeClip = resolveTrackKeyframeClip(
173
196
  els,
174
197
  laneCounts,
@@ -178,22 +201,22 @@ export function TimelineLanes({
178
201
  const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
179
202
  const keyframeClipExpanded =
180
203
  keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
181
- // Minted here because this is the only place that sees BOTH ends of
182
- // the disclosure: the caret in the sticky header and the diamond lanes
183
- // on the canvas. Keyed by display row, not by `trackNum`, which is a
184
- // fractional sort key and would mint ids like `...-0.16666666666666666`.
204
+ // Link the sticky caret to the canvas lanes with a stable display-row id.
185
205
  const lanesId = `${lanesIdPrefix}-track-${row}`;
186
206
  return (
187
207
  <TimelineTrackRow
188
208
  key={rowKey}
189
209
  index={row}
190
210
  rowKey={rowKey}
191
- rowCount={displayTrackOrder.length}
211
+ logicalRow={logicalRow}
212
+ propertyRows={trackLogicalRows.slice(1)}
213
+ lanesId={lanesId}
192
214
  top={rowGeometry.getRowTop(row)}
193
215
  height={rowHeight}
194
216
  virtualized={rowsVirtualized}
195
217
  background={rowBackground}
196
218
  borderColor={theme.rowBorder}
219
+ rovingTargetId={keyboard.rovingTargetId}
197
220
  >
198
221
  <TimelineTrackHeader
199
222
  trackNumber={trackNum}
@@ -224,8 +247,11 @@ export function TimelineLanes({
224
247
  onToggleTrackHidden={onToggleTrackHidden}
225
248
  onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
226
249
  onSeek={onSeek}
250
+ rovingTargetId={keyboard.rovingTargetId}
227
251
  />
228
252
  <div
253
+ role="gridcell"
254
+ aria-colindex={2}
229
255
  style={{
230
256
  width: trackContentWidth,
231
257
  marginLeft: contentGutter, // room for a 0% diamond left of t=0
@@ -297,9 +323,8 @@ export function TimelineLanes({
297
323
  const isSelected =
298
324
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
299
325
  const isComposition = !!el.compositionSrc;
300
- // elementKey (el.key ?? el.id) is already unique per clip; do NOT
301
- // fold in the map index, or a splice/reorder remounts every clip
302
- // at/after the change (DOM flash, drag interruption).
326
+ // The element identity is already unique per clip. Never fold in the map
327
+ // index, or a splice/reorder remounts every clip at/after the change.
303
328
  const clipKey = elementKey;
304
329
  const isDraggingClip =
305
330
  draggedClip?.started === true &&
@@ -307,11 +332,8 @@ export function TimelineLanes({
307
332
  getTimelineElementIdentity(draggedElement) === elementKey;
308
333
  if (isDraggingClip) return null;
309
334
  const previewElement = getPreviewElement(el);
310
- // Passenger of a live multi-drag: slide by the SAME formation
311
- // delta (the grabbed clip's group-clamped delta) via a
312
- // compositor transform on a same-geometry wrapper (absolute
313
- // inset-0 → identical offset parent, so the clip's own
314
- // left/top are preserved), plus the ghost's elevated z/opacity.
335
+ // Passenger of a live multi-drag: preserve the formation without changing
336
+ // the passenger's timeline data until the owning drag commits.
315
337
  const isPassenger =
316
338
  multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
317
339
  const passengerOffsetPx = isPassenger
@@ -336,6 +358,9 @@ export function TimelineLanes({
336
358
  capabilities={capabilities}
337
359
  theme={theme}
338
360
  isComposition={isComposition}
361
+ tabIndex={
362
+ keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
363
+ }
339
364
  onHoverStart={() => setHoveredClip(clipKey)}
340
365
  onHoverEnd={() => setHoveredClip(null)}
341
366
  onResizeStart={
@@ -473,41 +498,33 @@ export function TimelineLanes({
473
498
  renderClipContent,
474
499
  renderClipOverlay,
475
500
  )}
476
- {!showsLanes && keyframeCache?.get(elementKey) && (
477
- <TimelineClipDiamonds
478
- keyframesData={keyframeCache.get(elementKey)!}
479
- clipWidthPx={Math.max(previewElement.duration * pps, 4)}
480
- clipHeightPx={rowHeight - 2 * CLIP_Y}
481
- clipDuration={previewElement.duration}
482
- beatsActive={beatStripOnTrack}
483
- accentColor={clipStyle.accent}
484
- isSelected={isSelected}
485
- currentPercentage={
486
- previewElement.duration > 0
487
- ? ((currentTime - previewElement.start) / previewElement.duration) *
488
- 100
489
- : 0
490
- }
491
- elementId={elementKey}
492
- selectedKeyframes={selectedKeyframes}
493
- onClickKeyframe={(_elId, target) =>
494
- onClickKeyframe?.(previewElement, target)
495
- }
496
- onShiftClickKeyframe={onShiftClickKeyframe}
497
- onContextMenuKeyframe={onContextMenuKeyframe}
498
- onMoveKeyframe={onMoveKeyframe}
499
- onSelectSegment={onSelectSegment}
500
- suppressClickRef={suppressClickRef}
501
- />
502
- )}
503
501
  </TimelineClip>
504
502
  );
505
- // Mounted for the track's keyframe clip in BOTH disclosure
506
- // states, so the header caret's aria-controls resolves while
507
- // collapsed too; collapsed just feeds it no animations, so
508
- // the wrapper renders empty. The key is stable across a
509
- // multi-drag: without it the passenger branch below remounts
510
- // this subtree and interrupts the gesture.
503
+ const compactKeyframes = keyframeCache?.get(elementKey);
504
+ const compactDiamonds = !showsLanes && compactKeyframes && (
505
+ <TimelineCompactDiamonds
506
+ key={`${clipKey}-diamonds`}
507
+ element={previewElement}
508
+ elementId={elementKey}
509
+ keyframesData={compactKeyframes}
510
+ pixelsPerSecond={pps}
511
+ rowHeight={rowHeight}
512
+ beatsActive={beatStripOnTrack}
513
+ accentColor={clipStyle.accent}
514
+ isSelected={isSelected}
515
+ currentTime={currentTime}
516
+ selectedKeyframes={selectedKeyframes}
517
+ rovingTargetId={keyboard.rovingTargetId}
518
+ onClickKeyframe={onClickKeyframe}
519
+ onShiftClickKeyframe={onShiftClickKeyframe}
520
+ onContextMenuKeyframe={onContextMenuKeyframe}
521
+ onMoveKeyframe={onMoveKeyframe}
522
+ onSelectSegment={onSelectSegment}
523
+ suppressClickRef={suppressClickRef}
524
+ />
525
+ );
526
+ // Keep this shell mounted while collapsed so aria-controls stays valid
527
+ // and multi-drag cannot remount the subtree mid-gesture.
511
528
  const propertyLanes = isTrackKeyframeClip && (
512
529
  <TimelinePropertyLanes
513
530
  key={`${clipKey}-property-lanes`}
@@ -529,6 +546,7 @@ export function TimelineLanes({
529
546
  }
530
547
  elementId={elementKey}
531
548
  selectedKeyframes={selectedKeyframes}
549
+ rovingTargetId={keyboard.rovingTargetId}
532
550
  onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
533
551
  onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
534
552
  onShiftClickKeyframe={(target) =>
@@ -544,14 +562,12 @@ export function TimelineLanes({
544
562
  suppressClickRef={suppressClickRef}
545
563
  />
546
564
  );
547
- // Keep one keyed top-level child per element. Returning an
548
- // array here makes React reconcile the outer array by
549
- // position, so a window shift remounts otherwise stable
550
- // clip keys and can tear down focus mid-reveal.
565
+ // One keyed child prevents window shifts from remounting focused clips.
551
566
  if (!isPassenger) {
552
567
  return (
553
568
  <Fragment key={clipKey}>
554
569
  {clip}
570
+ {compactDiamonds}
555
571
  {propertyLanes}
556
572
  </Fragment>
557
573
  );
@@ -568,6 +584,7 @@ export function TimelineLanes({
568
584
  }}
569
585
  >
570
586
  {clip}
587
+ {compactDiamonds}
571
588
  {propertyLanes}
572
589
  </div>
573
590
  );
@@ -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
  }
@@ -29,7 +29,7 @@ export interface TimelineClipDiamondsProps {
29
29
  keyframesData: KeyframeCacheEntry;
30
30
  clipWidthPx: number;
31
31
  clipHeightPx: number;
32
- /** Needed to compare the playhead to keyframes in output-frame time. */
32
+ /** Needed to compare the playhead to keyframes and voice their absolute time. */
33
33
  clipDuration: number;
34
34
  /** Beat-dot strip is shown on this track → shrink diamonds + drop them into
35
35
  * the bottom half so they clear the strip at the top. */
@@ -38,7 +38,11 @@ export interface TimelineClipDiamondsProps {
38
38
  isSelected: boolean;
39
39
  currentPercentage: number;
40
40
  elementId: string;
41
+ /** Absolute clip start, used only to voice a keyframe's time in its label. */
42
+ clipStart?: number;
41
43
  selectedKeyframes: ReadonlySet<string>;
44
+ /** Focus id of the one timeline control currently in the tab order. */
45
+ rovingTargetId?: string | null;
42
46
  onClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
43
47
  onShiftClickKeyframe?: (elementId: string, keyframe: TimelineKeyframeTarget) => void;
44
48
  onContextMenuKeyframe?: (
@@ -86,12 +90,15 @@ export interface TimelineDiamondLaneProps extends Omit<
86
90
  }
87
91
 
88
92
  export const DIAMOND_RATIO = 0.8;
89
- // Percentage tolerance for rendering keyframes near clip boundaries. Keyframes
90
- // slightly outside [0, 100] (from rounding or stale cache during the async
91
- // persist → reload cycle) are still rendered (the clip is overflow-visible) at
92
- // their true position rather than hidden.
93
- export const KF_MIN_PCT = -5;
94
- export const KF_MAX_PCT = 105;
93
+
94
+ /** Absolute time of a keyframe, for the screen-reader label. */
95
+ export function keyframeTimeLabel(
96
+ clipStart: number,
97
+ clipDuration: number,
98
+ percentage: number,
99
+ ): string {
100
+ return `${Number((clipStart + (clipDuration * percentage) / 100).toFixed(2))}s`;
101
+ }
95
102
 
96
103
  /**
97
104
  * The full identity of a diamond, used by every callback and by the selection