@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
@@ -10,10 +10,10 @@ import {
10
10
  subscribeTimelineKeyframeRetimePreview,
11
11
  type TimelineKeyframeRetimeHandle,
12
12
  } from "./useTimelineKeyframeHandlers";
13
+ import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
13
14
  import {
14
15
  DIAMOND_RATIO,
15
- KF_MAX_PCT,
16
- KF_MIN_PCT,
16
+ keyframeTimeLabel,
17
17
  keyframeTarget,
18
18
  type TimelineClipDiamondsProps,
19
19
  type TimelineDiamondKeyframe,
@@ -68,13 +68,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
68
68
  keyframesData,
69
69
  clipWidthPx,
70
70
  clipHeightPx,
71
- clipDuration,
72
71
  beatsActive,
73
72
  accentColor,
74
73
  isSelected,
75
74
  currentPercentage,
76
75
  elementId,
76
+ clipStart = 0,
77
+ clipDuration = 0,
77
78
  selectedKeyframes,
79
+ rovingTargetId = null,
78
80
  onClickKeyframe,
79
81
  onShiftClickKeyframe,
80
82
  onContextMenuKeyframe,
@@ -140,9 +142,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
140
142
  // (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
141
143
  // shrunken box: 24px there would reach up into the beat strip.
142
144
  const hitHeight = beatsActive ? diamondSize : 24;
143
- const sorted = keyframesData.keyframes
144
- .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
145
- .sort((a, b) => a.percentage - b.percentage);
145
+ // Keyframes authored outside the element's visible clip window are parked at
146
+ // the boundary rather than hidden: dropping them made the lane's count
147
+ // disagree with its diamonds and left users unable to inspect or remove state
148
+ // that still affects the clip once it appears.
149
+ const sorted = [...keyframesData.keyframes].sort((a, b) => a.percentage - b.percentage);
150
+ const beforeClip = sorted.filter((keyframe) => keyframe.percentage < 0);
151
+ const afterClip = sorted.filter((keyframe) => keyframe.percentage > 100);
152
+ const boundaryStep = Math.max(6, Math.round(diamondSize * 0.55));
146
153
  // The neighbour clamp bounds a dragged diamond between its immediate siblings
147
154
  // so a retime can't reorder the tween. Siblings means "keyframes of the SAME
148
155
  // tween": a merged row interleaves several animations, and two of them
@@ -175,16 +182,30 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
175
182
  clipPcts: row.map((s) => s.clipPct),
176
183
  });
177
184
  }
178
- const centerXOf = (percentage: number) =>
179
- Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
185
+ const centerXOf = (keyframe: TimelineDiamondKeyframe, percentage = keyframe.percentage) => {
186
+ if (percentage < 0) {
187
+ const rank = beforeClip.indexOf(keyframe);
188
+ return -(beforeClip.length - Math.max(0, rank)) * boundaryStep;
189
+ }
190
+ if (percentage > 100) {
191
+ const rank = afterClip.indexOf(keyframe);
192
+ return clipWidthPx + (Math.max(0, rank) + 1) * boundaryStep;
193
+ }
194
+ return (percentage / 100) * clipWidthPx;
195
+ };
180
196
  // One record per diamond, carrying its own geometry, so the connector and
181
197
  // button passes below read neighbours as values instead of index lookups.
182
198
  const markers = sorted.map((keyframe, index) => {
183
- const centerX = centerXOf(keyframe.percentage);
199
+ const centerX = centerXOf(keyframe);
200
+ // Parked diamonds sit on their own boundary spacing, so the in-clip
201
+ // neighbour-gap shrink would only make them unreadable.
202
+ if (keyframe.percentage < 0 || keyframe.percentage > 100) {
203
+ return { keyframe, centerX, hitWidth: diamondSize, visualSize: diamondSize };
204
+ }
184
205
  const previous = sorted[index - 1];
185
206
  const next = sorted[index + 1];
186
- const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
187
- const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
207
+ const previousGap = previous ? centerX - centerXOf(previous) : Infinity;
208
+ const nextGap = next ? centerXOf(next) - centerX : Infinity;
188
209
  const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
189
210
  const hitWidth = Math.min(diamondSize, nearestGap);
190
211
  return {
@@ -226,6 +247,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
226
247
  <TimelineDiamondConnectors
227
248
  markers={markers}
228
249
  centerY={centerY}
250
+ elementId={elementId}
251
+ clipStart={clipStart}
252
+ clipDuration={clipDuration}
253
+ rovingTargetId={rovingTargetId}
229
254
  baseColor={baseColor}
230
255
  baseOpacity={baseOpacity}
231
256
  groupAware={groupAware}
@@ -237,7 +262,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
237
262
  {markers.map((marker, i) => {
238
263
  const kf = marker.keyframe;
239
264
  const target = keyframeTarget(kf);
265
+ const focusId = timelineKeyframeFocusId(elementId, target);
240
266
  const kfKey = timelineKeyframeSelectionKey(elementId, target);
267
+ const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null;
241
268
  // Clamp against this keyframe's own tween, not the whole merged row.
242
269
  const siblingRow = siblingRows.get(kf.animationId);
243
270
  const siblingClipPcts = siblingRow?.clipPcts ?? [];
@@ -249,7 +276,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
249
276
  // The 0% diamond's left half lands in the reserved left gutter (the
250
277
  // content origin is inset past the label column, Figma-style) so it stays
251
278
  // fully visible instead of being clipped by the sticky label column.
252
- const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
279
+ const leftPx = centerXOf(kf, renderPct) - marker.hitWidth / 2;
253
280
  const isKfSelected = selectedKeyframes.has(kfKey);
254
281
  const atPlayhead = kf === playheadKeyframe;
255
282
  const isHighlighted = isKfSelected || atPlayhead;
@@ -306,6 +333,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
306
333
  key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
307
334
  type="button"
308
335
  className="absolute"
336
+ data-timeline-focus-id={focusId}
309
337
  data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
310
338
  data-keyframe-percentage={
311
339
  groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
@@ -313,6 +341,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
313
341
  data-keyframe-at-playhead={String(atPlayhead)}
314
342
  data-keyframe-selected={String(isKfSelected)}
315
343
  aria-current={atPlayhead ? "time" : undefined}
344
+ data-keyframe-outside-clip={boundary ?? undefined}
345
+ tabIndex={focusId === rovingTargetId ? 0 : -1}
346
+ aria-label={`${kf.propertyGroup ?? "Motion"} keyframe at ${keyframeTimeLabel(clipStart, clipDuration, kf.percentage)}${boundary ? ` (${boundary} clip)` : ""}`}
316
347
  aria-pressed={isKfSelected}
317
348
  style={{
318
349
  left: leftPx,
@@ -335,6 +366,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
335
366
  onPointerDown={onPointerDown}
336
367
  onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
337
368
  onPointerUp={onPointerUp}
369
+ // Keyboard activation only (detail 0): pointer presses already
370
+ // resolve through the pointerup path above.
371
+ onClick={(e) => {
372
+ if (e.detail !== 0) return;
373
+ e.stopPropagation();
374
+ suppressNextClick();
375
+ if (e.shiftKey) onShiftClickKeyframe?.(target);
376
+ else onClickKeyframe?.(target);
377
+ }}
338
378
  onPointerCancel={
339
379
  canDrag
340
380
  ? (e) => {
@@ -348,7 +388,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
348
388
  e.stopPropagation();
349
389
  onContextMenuKeyframe?.(e, target);
350
390
  }}
351
- title={`${roundPct(kf.percentage)}%`}
391
+ title={`${roundPct(kf.percentage)}%${boundary ? ` · ${boundary} clip` : ""}`}
352
392
  >
353
393
  <svg
354
394
  width={marker.visualSize}
@@ -0,0 +1,84 @@
1
+ import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
2
+ import { CLIP_Y } from "./timelineLayout";
3
+ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
4
+ import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
5
+
6
+ interface TimelineCompactDiamondsProps extends Pick<
7
+ TimelineLaneBaseProps,
8
+ | "currentTime"
9
+ | "selectedKeyframes"
10
+ | "onClickKeyframe"
11
+ | "onShiftClickKeyframe"
12
+ | "onContextMenuKeyframe"
13
+ | "onMoveKeyframe"
14
+ | "onSelectSegment"
15
+ | "suppressClickRef"
16
+ > {
17
+ element: TimelineElement;
18
+ elementId: string;
19
+ keyframesData: KeyframeCacheEntry;
20
+ pixelsPerSecond: number;
21
+ rowHeight: number;
22
+ beatsActive: boolean;
23
+ accentColor: string;
24
+ isSelected: boolean;
25
+ rovingTargetId: string | null;
26
+ }
27
+
28
+ /** Inline diamonds shown while the clip's property lanes are collapsed. */
29
+ export function TimelineCompactDiamonds({
30
+ element,
31
+ elementId,
32
+ keyframesData,
33
+ pixelsPerSecond,
34
+ rowHeight,
35
+ beatsActive,
36
+ accentColor,
37
+ isSelected,
38
+ currentTime,
39
+ selectedKeyframes,
40
+ rovingTargetId,
41
+ onClickKeyframe,
42
+ onShiftClickKeyframe,
43
+ onContextMenuKeyframe,
44
+ onMoveKeyframe,
45
+ onSelectSegment,
46
+ suppressClickRef,
47
+ }: TimelineCompactDiamondsProps) {
48
+ const width = Math.max(element.duration * pixelsPerSecond, 4);
49
+ return (
50
+ <div
51
+ className="absolute pointer-events-none"
52
+ style={{
53
+ left: element.start * pixelsPerSecond,
54
+ top: CLIP_Y,
55
+ width,
56
+ height: rowHeight - 2 * CLIP_Y,
57
+ zIndex: isSelected ? 11 : 6,
58
+ }}
59
+ >
60
+ <TimelineClipDiamonds
61
+ keyframesData={keyframesData}
62
+ clipWidthPx={width}
63
+ clipHeightPx={rowHeight - 2 * CLIP_Y}
64
+ beatsActive={beatsActive}
65
+ accentColor={accentColor}
66
+ isSelected={isSelected}
67
+ currentPercentage={
68
+ element.duration > 0 ? ((currentTime - element.start) / element.duration) * 100 : 0
69
+ }
70
+ elementId={elementId}
71
+ clipStart={element.start}
72
+ clipDuration={element.duration}
73
+ selectedKeyframes={selectedKeyframes}
74
+ rovingTargetId={rovingTargetId}
75
+ onClickKeyframe={(_id, target) => onClickKeyframe?.(element, target)}
76
+ onShiftClickKeyframe={onShiftClickKeyframe}
77
+ onContextMenuKeyframe={onContextMenuKeyframe}
78
+ onMoveKeyframe={onMoveKeyframe}
79
+ onSelectSegment={onSelectSegment}
80
+ suppressClickRef={suppressClickRef}
81
+ />
82
+ </div>
83
+ );
84
+ }
@@ -2,7 +2,8 @@ import { Fragment, useRef } from "react";
2
2
  import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
3
3
  import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
4
4
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
5
- import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
5
+ import { keyframeTimeLabel, type TimelineDiamondKeyframe } from "./timelineDiamondTypes";
6
+ import { timelineEaseFocusId } from "./timelineNavigationIdentity";
6
7
 
7
8
  /** One diamond's geometry within its row, as computed by the lane. */
8
9
  export interface TimelineDiamondMarker {
@@ -21,6 +22,10 @@ export interface TimelineDiamondMarker {
21
22
  export function TimelineDiamondConnectors({
22
23
  markers,
23
24
  centerY,
25
+ elementId,
26
+ clipStart,
27
+ clipDuration,
28
+ rovingTargetId,
24
29
  baseColor,
25
30
  baseOpacity,
26
31
  groupAware,
@@ -30,6 +35,11 @@ export function TimelineDiamondConnectors({
30
35
  }: {
31
36
  markers: readonly TimelineDiamondMarker[];
32
37
  centerY: number;
38
+ elementId: string;
39
+ clipStart: number;
40
+ clipDuration: number;
41
+ /** Focus id of the one timeline control currently in the tab order. */
42
+ rovingTargetId: string | null;
33
43
  baseColor: string;
34
44
  baseOpacity: number;
35
45
  groupAware: boolean;
@@ -48,6 +58,7 @@ export function TimelineDiamondConnectors({
48
58
  if (x2 - x1 < 1) return null;
49
59
  const connectorLeft = x1 + previous.visualSize / 2;
50
60
  const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
61
+ const target = keyframeTarget(kf);
51
62
  return (
52
63
  <Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
53
64
  <div
@@ -70,7 +81,14 @@ export function TimelineDiamondConnectors({
70
81
  width={x2 - x1}
71
82
  centerY={centerY}
72
83
  ease={kf.ease ?? globalEase}
73
- target={keyframeTarget(kf)}
84
+ target={target}
85
+ focusId={timelineEaseFocusId(elementId, target)}
86
+ rovingTargetId={rovingTargetId}
87
+ afterLabel={keyframeTimeLabel(
88
+ clipStart,
89
+ clipDuration,
90
+ previous.keyframe.percentage,
91
+ )}
74
92
  // connectorWidth is the clear span between the two diamonds'
75
93
  // edges, so a 24x24 target centred in it overhangs a diamond as
76
94
  // soon as the span is narrower than 24. The segment wrapper sits
@@ -111,6 +129,9 @@ function SegmentEaseControl({
111
129
  centerY,
112
130
  ease,
113
131
  target,
132
+ focusId,
133
+ rovingTargetId,
134
+ afterLabel,
114
135
  roomForFullTarget,
115
136
  onSelectSegment,
116
137
  }: {
@@ -119,6 +140,11 @@ function SegmentEaseControl({
119
140
  centerY: number;
120
141
  ease: string;
121
142
  target: TimelineKeyframeTarget;
143
+ focusId: string;
144
+ /** Focus id of the one timeline control currently in the tab order. */
145
+ rovingTargetId: string | null;
146
+ /** Time label of the keyframe this segment starts at, for the accessible name. */
147
+ afterLabel: string;
122
148
  roomForFullTarget: boolean;
123
149
  onSelectSegment: (target: TimelineKeyframeTarget) => void;
124
150
  }) {
@@ -151,7 +177,9 @@ function SegmentEaseControl({
151
177
  <button
152
178
  type="button"
153
179
  data-keyframe-ease-button=""
154
- aria-label={`Edit ${ease} easing`}
180
+ data-timeline-focus-id={focusId}
181
+ tabIndex={focusId === rovingTargetId ? 0 : -1}
182
+ aria-label={`Edit ${ease} easing after ${afterLabel}`}
155
183
  title={`Edit ${ease} easing`}
156
184
  // A visible 24x24 badge would collide with the diamonds either side, so
157
185
  // the WCAG 2.2 (2.5.8) target is met with a centered transparent
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
9
9
  import { defaultTimelineTheme } from "./timelineTheme";
10
10
  import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
11
11
  import { createTimelineClipIndex } from "../lib/timelineClipIndex";
12
+ import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
12
13
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
13
14
  import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
14
15
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -26,6 +27,17 @@ afterEach(() => {
26
27
  const TRACK_A = 1 / 6;
27
28
  const TRACK_B = 0.5;
28
29
 
30
+ /** Every string a screen reader or a sighted user actually reads. */
31
+ function visibleText(host: HTMLElement): string {
32
+ return host.textContent ?? "";
33
+ }
34
+
35
+ function ariaLabels(host: HTMLElement): string {
36
+ return Array.from(host.querySelectorAll("[aria-label]"))
37
+ .map((el) => el.getAttribute("aria-label") ?? "")
38
+ .join(" ");
39
+ }
40
+
29
41
  function element(id: string, track: number): TimelineElement {
30
42
  return { id, label: id, tag: "div", start: 0, duration: 2, track };
31
43
  }
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
94
106
  rowGeometry={getTimelineRowGeometry(rowHeights)}
95
107
  virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
96
108
  rowsVirtualized={false}
109
+ focusedTargetId={null}
110
+ logicalRows={buildTimelineLogicalRows({
111
+ tracks,
112
+ displayTrackOrder,
113
+ laneCounts,
114
+ selectedElementId: null,
115
+ selectedElementIds: next.selectedElementIds ?? new Set(),
116
+ expandedClipIds: new Set(next.expandedClipIds ?? []),
117
+ gsapAnimations,
118
+ })}
97
119
  clipIndex={createTimelineClipIndex(tracks)}
98
120
  renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
99
121
  pinnedClipIdentities={new Set()}
@@ -152,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
152
174
 
153
175
  expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
154
176
  expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
155
- expect(view.host.innerHTML).not.toContain("0.16666666666666666");
177
+ // Only what a user reads. The fractional key still identifies the row in
178
+ // `id` / `data-` attributes, which is exactly where an opaque sort key
179
+ // belongs.
180
+ expect(visibleText(view.host)).not.toContain("0.16666666666666666");
181
+ expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
156
182
  act(() => view.root.unmount());
157
183
  });
158
184
 
@@ -179,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
179
205
  onContextMenuLane,
180
206
  });
181
207
 
182
- // Row children: [sticky header column, time-mapped track content]. The rows
183
- // sit inside the lanes list, which is what carries the virtualization
184
- // positioning context.
185
- const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
186
- const secondTrackContent = rows[1]?.children.item(1);
208
+ // The lane's own content cell: the track row's second child, after the
209
+ // sticky header column.
210
+ const secondTrackContent = view.host
211
+ .querySelectorAll("[data-timeline-row]")[1]
212
+ ?.querySelector('[role="row"]')
213
+ ?.children.item(1);
187
214
  act(() => {
188
215
  secondTrackContent?.dispatchEvent(
189
216
  new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
@@ -238,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
238
265
  );
239
266
  const firstIds = idsFor(first.host);
240
267
  const secondIds = idsFor(second.host);
268
+ const cellIdsFor = (host: HTMLElement) =>
269
+ new Set(
270
+ Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
271
+ cell.getAttribute("id"),
272
+ ).filter((id): id is string => id !== null),
273
+ );
274
+ const ownedIdsFor = (host: HTMLElement) =>
275
+ Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
276
+ owner.getAttribute("aria-owns"),
277
+ ).filter((id): id is string => id !== null);
278
+ const firstCellIds = cellIdsFor(first.host);
279
+ const secondCellIds = cellIdsFor(second.host);
241
280
 
281
+ for (const { host } of [first, second]) {
282
+ const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
283
+ expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
284
+ expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
285
+ expect(
286
+ [...host.querySelectorAll('[role="rowheader"]')].every(
287
+ (cell) => cell.getAttribute("aria-colindex") === "1",
288
+ ),
289
+ ).toBe(true);
290
+ expect(
291
+ [...host.querySelectorAll('[role="gridcell"]')].every(
292
+ (cell) => cell.getAttribute("aria-colindex") === "2",
293
+ ),
294
+ ).toBe(true);
295
+ }
242
296
  expect(firstIds.length).toBeGreaterThan(0);
243
297
  expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
298
+ expect(firstCellIds.size).toBeGreaterThan(0);
299
+ expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
300
+ expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
301
+ expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
244
302
  // Still a legal CSS id selector: the aria-controls lookups above use `#id`.
245
303
  for (const id of [...firstIds, ...secondIds]) {
246
304
  expect(id).toMatch(/^[A-Za-z][\w-]*$/);