@hyperframes/studio 0.7.78 → 0.7.80

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 (103) hide show
  1. package/dist/assets/{hyperframes-player-uiAAPKvw.js → hyperframes-player-Csai0_vh.js} +1 -1
  2. package/dist/assets/{index-CDSTMtUs.js → index-B5aYTg-4.js} +1 -1
  3. package/dist/assets/{index-BSZrK0bx.js → index-BhdJN49y.js} +202 -202
  4. package/dist/assets/{index-r1tKKlU7.js → index-By-YiRxq.js} +1 -1
  5. package/dist/assets/index-D379YOKT.css +1 -0
  6. package/dist/index.d.ts +45 -3
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +1891 -1522
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/components/StudioLeftSidebar.tsx +10 -5
  12. package/src/components/StudioRightPanel.tsx +7 -5
  13. package/src/components/TimelineToolbar.tsx +3 -1
  14. package/src/components/editor/AnimationCard.test.tsx +225 -18
  15. package/src/components/editor/AnimationCard.tsx +24 -3
  16. package/src/components/editor/EaseCurveSection.test.tsx +147 -2
  17. package/src/components/editor/EaseCurveSection.tsx +77 -15
  18. package/src/components/editor/GsapAnimationSection.test.tsx +102 -0
  19. package/src/components/editor/GsapAnimationSection.tsx +6 -1
  20. package/src/components/editor/KeyframeEaseList.tsx +4 -0
  21. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +58 -20
  23. package/src/components/editor/PropertyPanel.tsx +7 -7
  24. package/src/components/editor/PropertyPanelFlat.tsx +6 -91
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +8 -1
  26. package/src/components/editor/gsapAnimationCallbacks.ts +8 -0
  27. package/src/components/editor/holdEaseSeek.test.ts +37 -0
  28. package/src/components/editor/keyframeRetime.test.ts +42 -0
  29. package/src/components/editor/keyframeRetime.ts +4 -1
  30. package/src/components/editor/motionPathSelection.test.ts +65 -0
  31. package/src/components/editor/motionPathSelection.ts +13 -3
  32. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  33. package/src/components/editor/propertyPanelColor.tsx +32 -26
  34. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +69 -0
  35. package/src/components/editor/propertyPanelFlatMotionSection.tsx +2 -1
  36. package/src/components/editor/propertyPanelFlatProps.ts +91 -0
  37. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  38. package/src/components/editor/propertyPanelTypes.ts +2 -0
  39. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  40. package/src/components/pointerTargetSize.test.tsx +65 -0
  41. package/src/components/sidebar/CompositionsTab.tsx +4 -1
  42. package/src/contexts/DomEditContext.tsx +4 -0
  43. package/src/hooks/gsapDragCommit.ts +43 -10
  44. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  45. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +195 -43
  46. package/src/hooks/gsapKeyframeCacheHelpers.ts +190 -83
  47. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  48. package/src/hooks/gsapResizeIntercept.ts +2 -2
  49. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  50. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  51. package/src/hooks/gsapShared.test.ts +144 -0
  52. package/src/hooks/gsapShared.ts +264 -0
  53. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  54. package/src/hooks/gsapTweenSynth.test.ts +70 -15
  55. package/src/hooks/gsapTweenSynth.ts +50 -23
  56. package/src/hooks/keyframeCacheAstLoad.ts +9 -83
  57. package/src/hooks/newTweenTarget.test.ts +364 -0
  58. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  59. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  60. package/src/hooks/timelineTrackVisibility.ts +11 -1
  61. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  62. package/src/hooks/useDomEditSession.test.tsx +152 -3
  63. package/src/hooks/useDomEditSession.ts +4 -53
  64. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  65. package/src/hooks/useEnableKeyframes.ts +18 -5
  66. package/src/hooks/useGestureCommit.ts +21 -4
  67. package/src/hooks/useGsapScriptCommits.ts +3 -0
  68. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  69. package/src/hooks/useGsapTweenCache.ts +44 -45
  70. package/src/hooks/useKeyframeEaseCommits.ts +78 -0
  71. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +21 -0
  72. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -12
  73. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  74. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  75. package/src/player/components/ShortcutsPanel.tsx +11 -15
  76. package/src/player/components/Timeline.test.ts +3 -1
  77. package/src/player/components/TimelineCanvas.tsx +2 -1
  78. package/src/player/components/TimelineClipDiamonds.test.tsx +35 -8
  79. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  80. package/src/player/components/TimelineDiamondConnectors.tsx +118 -72
  81. package/src/player/components/TimelineLanes.test.tsx +281 -0
  82. package/src/player/components/TimelineLanes.tsx +43 -97
  83. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  84. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  85. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  86. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  87. package/src/player/components/timelineDiamondTypes.ts +4 -3
  88. package/src/player/components/timelineKeyframeIdentity.ts +3 -0
  89. package/src/player/components/timelineLaneProps.ts +86 -0
  90. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  91. package/src/player/components/timelineTrackDisplay.ts +38 -0
  92. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +52 -32
  93. package/src/player/components/useTimelineKeyframeHandlers.ts +1 -0
  94. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  95. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  96. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  97. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  98. package/src/player/hooks/useExpandedTimelineElements.test.ts +180 -2
  99. package/src/player/hooks/useExpandedTimelineElements.ts +70 -7
  100. package/src/player/store/keyframeSlice.ts +15 -5
  101. package/src/telemetry/client.test.ts +12 -3
  102. package/src/telemetry/client.ts +3 -2
  103. package/dist/assets/index-DpkO2Hvw.css +0 -1
@@ -1,28 +1,21 @@
1
- import { type ReactNode } from "react";
2
- import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
1
+ import { useId } from "react";
3
2
  import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
4
3
  import { TimelineClip } from "./TimelineClip";
5
4
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
6
5
  import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
7
6
  import { TimelineTrackHeader } from "./TimelineTrackHeader";
8
7
  import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
9
- import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
10
- import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
8
+ import { trackDisplayNumber, trackDisplaySuffix } from "./timelineTrackDisplay";
9
+ import { clipTimingStart } from "../../hooks/gsapShared";
11
10
  import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing";
12
- import type { TimelineTheme } from "./timelineTheme";
13
11
  import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout";
14
- import {
15
- usePlayerStore,
16
- type TimelineElement,
17
- type KeyframeCacheEntry,
18
- } from "../store/playerStore";
19
- import type { DraggedClipState, ResizingClipState, BlockedClipState } from "./useTimelineClipDrag";
12
+ import { usePlayerStore, type TimelineElement } from "../store/playerStore";
20
13
  import {
21
14
  isMultiDragPassenger,
22
15
  multiDragPassengerOffsetPx,
23
16
  type MultiDragPreviewInput,
24
17
  } from "./timelineMultiDragPreview";
25
- import type { TrackVisualStyle } from "./timelineIcons";
18
+ import type { TimelineLaneBaseProps } from "./timelineLaneProps";
26
19
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
27
20
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
28
21
  import { trackStudioKeyframeLaneExpand } from "../../telemetry/events";
@@ -30,83 +23,6 @@ import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit";
30
23
  import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector";
31
24
  import { renderClipChildren } from "./timelineClipChildren";
32
25
 
33
- /**
34
- * Props shared by the scroll container ({@link TimelineCanvas}) and the lane
35
- * renderer below. TimelineCanvas passes these straight through via spread, so
36
- * they are declared once here and both prop types compose from this base — no
37
- * duplicated prop list.
38
- */
39
- export interface TimelineLaneBaseProps {
40
- pps: number;
41
- contentOrigin: number;
42
- contentGutter: number;
43
- trackContentWidth: number;
44
- theme: TimelineTheme;
45
- displayTrackOrder: number[];
46
- rowHeights: readonly number[];
47
- trackOrder: number[];
48
- tracks: [number, TimelineElement[]][];
49
- trackStyles: Map<number, TrackVisualStyle>;
50
- laneCounts: ReadonlyMap<string, number>;
51
- selectedElementId: string | null;
52
- selectedElementIds: Set<string>;
53
- hoveredClip: string | null;
54
- draggedClip: DraggedClipState | null;
55
- blockedClipRef: React.RefObject<BlockedClipState | null>;
56
- suppressClickRef: React.RefObject<boolean>;
57
- scrollRef: React.RefObject<HTMLDivElement | null>;
58
- renderClipContent?: (
59
- element: TimelineElement,
60
- style: { clip: string; label: string },
61
- ) => ReactNode;
62
- renderClipOverlay?: (element: TimelineElement) => ReactNode;
63
- onDrillDown?: (element: TimelineElement) => void;
64
- onSelectElement?: (element: TimelineElement | null) => void;
65
- setHoveredClip: (key: string | null) => void;
66
- setShowPopover: (v: boolean) => void;
67
- setRangeSelection: (v: null) => void;
68
- setResizingClip: (v: ResizingClipState | null) => void;
69
- setDraggedClip: (v: DraggedClipState | null) => void;
70
- setSelectedElementId: (id: string | null) => void;
71
- syncClipDragAutoScroll: (x: number, y: number) => void;
72
- shiftClickClipRef: React.RefObject<{
73
- element: TimelineElement;
74
- anchorX: number;
75
- anchorY: number;
76
- } | null>;
77
- getPreviewElement: (element: TimelineElement) => TimelineElement;
78
- getTrackStyle: (tag: string) => TrackVisualStyle;
79
- keyframeCache?: Map<string, KeyframeCacheEntry>;
80
- gsapAnimations: Map<string, GsapAnimation[]>;
81
- selectedKeyframes: Set<string>;
82
- currentTime: number;
83
- onSeek?: (time: number) => void;
84
- onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void;
85
- onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void;
86
- onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void;
87
- onContextMenuKeyframe?: (
88
- e: React.MouseEvent,
89
- elementId: string,
90
- target: TimelineKeyframeTarget,
91
- ) => void;
92
- onMoveKeyframe?: (
93
- elementId: string,
94
- keyframe: TimelineKeyframeTarget,
95
- toClipPercentage: number,
96
- propertyGroup?: string,
97
- tweenPercentage?: number,
98
- animationId?: string,
99
- ) => Promise<boolean>;
100
- onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void;
101
- /**
102
- * Right-click on EMPTY lane space (not on a clip — those preventDefault
103
- * before this fires — not the gutter/ruler, not below the lanes). `time` is
104
- * the timeline time (seconds) under the pointer on that lane.
105
- */
106
- onContextMenuLane?: (e: React.MouseEvent, track: number, time: number) => void;
107
- beatAnalysis?: MusicBeatAnalysis | null;
108
- }
109
-
110
26
  interface TimelineLanesProps extends TimelineLaneBaseProps {
111
27
  /** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
112
28
  draggedElement: TimelineElement | null;
@@ -174,6 +90,12 @@ export function TimelineLanes({
174
90
  onRazorSplit,
175
91
  onRazorSplitAll,
176
92
  }: TimelineLanesProps) {
93
+ // Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
94
+ // beside the main one) cannot both mint `...-track-0` and have every caret's
95
+ // aria-controls resolve to whichever mounted first. React's useId embeds
96
+ // colons, which are legal in an id and in aria-controls but need escaping in
97
+ // a CSS `#id` selector, so they come out here and the prefix stays plain.
98
+ const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
177
99
  const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
178
100
  const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
179
101
  const toggleClipExpandedTracked = (key: string) => {
@@ -191,6 +113,7 @@ export function TimelineLanes({
191
113
  // in a virtualizer if editorial workflows ever push very high clip counts.
192
114
  // fallow-ignore-next-line complexity
193
115
  displayTrackOrder.map((trackNum, row) => {
116
+ const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
194
117
  const rowHeight = getTimelineRowHeight(row, rowHeights);
195
118
  const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
196
119
  const ts = trackStyles.get(trackNum) ?? getTrackStyle("");
@@ -217,6 +140,11 @@ export function TimelineLanes({
217
140
  const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
218
141
  const keyframeClipExpanded =
219
142
  keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
143
+ // Minted here because this is the only place that sees BOTH ends of
144
+ // the disclosure: the caret in the sticky header and the diamond lanes
145
+ // on the canvas. Keyed by display row, not by `trackNum`, which is a
146
+ // fractional sort key and would mint ids like `...-0.16666666666666666`.
147
+ const lanesId = `${lanesIdPrefix}-track-${row}`;
220
148
  return (
221
149
  <div
222
150
  key={trackNum}
@@ -229,7 +157,16 @@ export function TimelineLanes({
229
157
  >
230
158
  <TimelineTrackHeader
231
159
  trackNumber={trackNum}
232
- trackLabel={els[0]?.label ?? els[0]?.domId ?? els[0]?.id ?? `Track ${trackNum}`}
160
+ // What gets announced. `trackNum` is a fractional z-order sort
161
+ // key, so it stays out of every label and in every callback.
162
+ trackDisplayNumber={displayNumber}
163
+ trackLabel={
164
+ els[0]?.label ??
165
+ els[0]?.domId ??
166
+ els[0]?.id ??
167
+ `Track${trackDisplaySuffix(displayNumber)}`
168
+ }
169
+ lanesId={lanesId}
233
170
  contentOrigin={contentOrigin}
234
171
  keyframeClip={keyframeClip}
235
172
  clipCount={els.length}
@@ -312,10 +249,9 @@ export function TimelineLanes({
312
249
  // Only the track's active keyframe clip shows expanded lanes;
313
250
  // other clips (incl. siblings on a shared track) show compact
314
251
  // diamonds on their own bar instead.
315
- const showsLanes =
316
- STUDIO_KEYFRAMES_ENABLED &&
317
- elementKey === keyframeClipKey &&
318
- keyframeClipExpanded;
252
+ const isTrackKeyframeClip =
253
+ STUDIO_KEYFRAMES_ENABLED && elementKey === keyframeClipKey;
254
+ const showsLanes = isTrackKeyframeClip && keyframeClipExpanded;
319
255
  const capabilities = getTimelineEditCapabilities(el);
320
256
  const isSelected =
321
257
  selectedElementId === elementKey || selectedElementIds.has(elementKey);
@@ -523,11 +459,21 @@ export function TimelineLanes({
523
459
  )}
524
460
  </TimelineClip>
525
461
  );
526
- const propertyLanes = showsLanes && (
462
+ // Mounted for the track's keyframe clip in BOTH disclosure
463
+ // states, so the header caret's aria-controls resolves while
464
+ // collapsed too; collapsed just feeds it no animations, so
465
+ // the wrapper renders empty. The key is stable across a
466
+ // multi-drag: without it the passenger branch below remounts
467
+ // this subtree and interrupts the gesture.
468
+ const propertyLanes = isTrackKeyframeClip && (
527
469
  <TimelinePropertyLanes
528
470
  key={`${clipKey}-property-lanes`}
529
- animations={gsapAnimations.get(elementKey) ?? []}
530
- clipStart={previewElement.start}
471
+ id={lanesId}
472
+ animations={showsLanes ? (gsapAnimations.get(elementKey) ?? []) : []}
473
+ // clipTimingStart, not the raw start: an expanded sub-comp
474
+ // child's start is host-absolute while its tweens are
475
+ // local to its own file.
476
+ clipStart={clipTimingStart(previewElement)}
531
477
  clipDuration={previewElement.duration}
532
478
  clipLeftPx={previewElement.start * pps}
533
479
  clipWidthPx={Math.max(previewElement.duration * pps, 4)}
@@ -7,10 +7,15 @@ import { afterEach, describe, expect, it, vi } from "vitest";
7
7
  import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
8
8
  import {
9
9
  getTimelinePropertyLanes,
10
+ resolveAnimIdForProperty,
10
11
  TimelinePropertyLanes,
11
12
  type TimelinePropertyLanesProps,
12
13
  } from "./TimelinePropertyLanes";
13
14
  import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity";
15
+ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
16
+ import { groupLabel } from "./trackHeaderLaneValues";
17
+ import { clipTimingStart } from "../../hooks/gsapShared";
18
+ import { resolveTimelineKeyframeTarget } from "../../components/nle/useTimelineEditCallbacks";
14
19
 
15
20
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
16
21
 
@@ -121,7 +126,111 @@ const POSITION_SEGMENT_ANIMATION = animation("position-tween", "position", [
121
126
  { percentage: 50, properties: { x: 50 } },
122
127
  ]);
123
128
 
129
+ /** A tween the parser leaves unclassified because it spans several groups. */
130
+ function ungroupedAnimation(
131
+ id: string,
132
+ properties: Record<string, number | string>,
133
+ ): GsapAnimation {
134
+ return {
135
+ id,
136
+ targetSelector: "#clip-1",
137
+ method: "to",
138
+ position: 0,
139
+ duration: 1,
140
+ properties,
141
+ };
142
+ }
143
+
124
144
  describe("TimelinePropertyLanes", () => {
145
+ // `{ x, opacity }` is the canonical HyperFrames entrance tween. The parser
146
+ // classifies it to `undefined` (two groups), which used to erase it from the
147
+ // lanes entirely — no caret, no reserved row, nothing to edit.
148
+ it("lanes a mixed-group tween once per group it animates", () => {
149
+ const lanes = getTimelinePropertyLanes(
150
+ [ungroupedAnimation("entrance", { x: 0, opacity: 1 })],
151
+ 0,
152
+ 1,
153
+ );
154
+
155
+ expect(lanes.map((lane) => lane.group).sort()).toEqual(["position", "visual"]);
156
+ for (const lane of lanes) {
157
+ expect(lane.keyframes.map((keyframe) => keyframe.percentage)).toEqual([0, 100]);
158
+ expect(lane.keyframes.every((keyframe) => keyframe.animationId === "entrance")).toBe(true);
159
+ }
160
+ });
161
+
162
+ it("lanes a tween whose properties are all unknown as one 'other' lane", () => {
163
+ const lanes = getTimelinePropertyLanes(
164
+ [ungroupedAnimation("rounded", { borderRadius: 12, fontSize: 24 })],
165
+ 0,
166
+ 1,
167
+ );
168
+
169
+ expect(lanes).toHaveLength(1);
170
+ expect(lanes[0]?.group).toBe("other");
171
+ expect(groupLabel("other", lanes[0]!.keyframes[0]!.properties)).toBe("BorderRadius");
172
+ });
173
+
174
+ // An expanded sub-composition child sits on the MASTER timeline at a
175
+ // host-absolute start while its tweens are parsed from its own file and are
176
+ // local to it. clipTimingStart is what brings the two into one frame.
177
+ it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
178
+ const child = { start: 16.5, duration: 2, expandedParentStart: 16 };
179
+ const local = animation("pill-tween", "position", [
180
+ { percentage: 0, properties: { x: 0 } },
181
+ { percentage: 100, properties: { x: 100 } },
182
+ ]);
183
+ local.position = 0.5;
184
+ local.resolvedStart = 0.5;
185
+ local.duration = 2;
186
+
187
+ const percentages = getTimelinePropertyLanes(
188
+ [local],
189
+ clipTimingStart(child),
190
+ child.duration,
191
+ ).flatMap((lane) => lane.keyframes.map((keyframe) => keyframe.percentage));
192
+
193
+ expect(percentages).toHaveLength(2);
194
+ for (const percentage of percentages) {
195
+ expect(percentage).toBeGreaterThanOrEqual(0);
196
+ expect(percentage).toBeLessThanOrEqual(100);
197
+ }
198
+ // Falsifier: the raw host-absolute start is what used to be passed.
199
+ expect(
200
+ getTimelinePropertyLanes([local], child.start, child.duration)[0]?.keyframes[0]?.percentage,
201
+ ).toBeLessThan(0);
202
+ });
203
+
204
+ it("still lanes a single-group tween exactly once", () => {
205
+ const lanes = getTimelinePropertyLanes(
206
+ [animation("position-tween", "position", [{ percentage: 0, properties: { x: 0, y: 0 } }])],
207
+ 0,
208
+ 1,
209
+ );
210
+
211
+ expect(lanes.map((lane) => lane.group)).toEqual(["position"]);
212
+ });
213
+
214
+ // A lane can merge several tweens, so an edit routed from it must carry the
215
+ // clicked keyframe's own animation identity — group matching alone is
216
+ // ambiguous once two tweens feed the same lane.
217
+ it("routes a mixed-tween lane edit to the tween that owns the keyframe", () => {
218
+ const mixed = ungroupedAnimation("entrance", { x: 40, opacity: 1 });
219
+ const sibling = animation("drift", "position", [
220
+ { percentage: 0, properties: { x: 0 } },
221
+ { percentage: 100, properties: { x: 9 } },
222
+ ]);
223
+ const lanes = getTimelinePropertyLanes([mixed, sibling], 0, 1);
224
+ const position = lanes.find((lane) => lane.group === "position");
225
+
226
+ expect(
227
+ resolveTimelineKeyframeTarget(100, position?.keyframes ?? [], [
228
+ { id: "entrance" },
229
+ { id: "drift", propertyGroup: "position" },
230
+ ]),
231
+ ).toEqual({ animId: "entrance", tweenPct: 100 });
232
+ });
233
+
125
234
  it("returns a position lane with synthesized endpoints for a flat tween", () => {
126
235
  const lanes = getTimelinePropertyLanes(
127
236
  [flatAnimation("position-tween", "position", { x: 420 })],
@@ -399,6 +508,68 @@ describe("TimelinePropertyLanes", () => {
399
508
  act(() => root.unmount());
400
509
  });
401
510
 
511
+ // The disclosure caret's aria-controls used to name a div in the STICKY LABEL
512
+ // COLUMN whose children are all absolutely positioned: it computed to 0x0 and
513
+ // held no diamonds. The real lanes had no wrapper at all to point at.
514
+ it("wraps the lanes in the identified element so aria-controls resolves to the diamonds", () => {
515
+ const animations = [
516
+ animation("position-tween", "position", [
517
+ { percentage: 0, properties: { x: 0 } },
518
+ { percentage: 100, properties: { x: 100 } },
519
+ ]),
520
+ animation("visual-tween", "visual", [
521
+ { percentage: 0, properties: { opacity: 0 } },
522
+ { percentage: 100, properties: { opacity: 1 } },
523
+ ]),
524
+ ];
525
+ const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations });
526
+ const wrapper = host.querySelector("#timeline-lanes-track-0");
527
+
528
+ expect(wrapper).not.toBeNull();
529
+ expect(wrapper?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(2);
530
+ expect(wrapper?.querySelectorAll("button[data-keyframe-percentage]").length).toBeGreaterThan(0);
531
+ // Load-bearing: a `position: relative` wrapper would become the containing
532
+ // block for the absolute lanes below and shift every one of them.
533
+ expect((wrapper as HTMLElement).style.position).toBe("");
534
+ act(() => root.unmount());
535
+ });
536
+
537
+ // happy-dom has no CSS engine, so measured geometry is always 0x0. The lanes'
538
+ // own inline offsets are what the component actually computes, so pin those.
539
+ it("leaves every lane's inline offsets untouched by the wrapper", () => {
540
+ const animations = [
541
+ animation("position-tween", "position", [{ percentage: 0, properties: { x: 0 } }]),
542
+ animation("visual-tween", "visual", [{ percentage: 0, properties: { opacity: 0 } }]),
543
+ ];
544
+ const { host, root } = renderPropertyLanes({
545
+ id: "timeline-lanes-track-0",
546
+ animations,
547
+ clipLeftPx: 120,
548
+ clipWidthPx: 200,
549
+ });
550
+
551
+ const lanes = Array.from(host.querySelectorAll<HTMLElement>("[data-timeline-property-lane]"));
552
+ expect(lanes.map((lane) => lane.style.top)).toEqual([
553
+ `${getTimelineLaneTop(0)}px`,
554
+ `${getTimelineLaneTop(1)}px`,
555
+ ]);
556
+ expect(lanes.map((lane) => lane.style.left)).toEqual(["120px", "120px"]);
557
+ expect(lanes.map((lane) => lane.style.width)).toEqual(["200px", "200px"]);
558
+ expect(lanes.map((lane) => lane.style.height)).toEqual([`${LANE_H}px`, `${LANE_H}px`]);
559
+ act(() => root.unmount());
560
+ });
561
+
562
+ // The wrapper is the aria-controls target in BOTH disclosure states, so a
563
+ // collapsed layer (no animations reach it) must still resolve the id.
564
+ it("still renders the identified wrapper when there are no lanes to show", () => {
565
+ const { host, root } = renderPropertyLanes({ id: "timeline-lanes-track-0", animations: [] });
566
+
567
+ const wrapper = host.querySelector("#timeline-lanes-track-0");
568
+ expect(wrapper).not.toBeNull();
569
+ expect(wrapper?.querySelectorAll("[data-timeline-property-lane]")).toHaveLength(0);
570
+ act(() => root.unmount());
571
+ });
572
+
402
573
  it("keeps the collapsed TimelineClipDiamonds positions and callback contract unchanged", () => {
403
574
  const onClickKeyframe = vi.fn();
404
575
  const COLLAPSED_IDENTITY = { animationId: "position-tween", propertyGroup: "position" };
@@ -448,3 +619,49 @@ describe("TimelinePropertyLanes", () => {
448
619
  act(() => root.unmount());
449
620
  });
450
621
  });
622
+
623
+ describe("resolveAnimIdForProperty", () => {
624
+ /** A legacy mixed tween: the parser leaves propertyGroup undefined for it. */
625
+ const mixed = {
626
+ id: "mixed-1",
627
+ targetSelector: "#box",
628
+ method: "to",
629
+ position: 0,
630
+ properties: {},
631
+ keyframes: {
632
+ keyframes: [
633
+ { percentage: 0, properties: { x: 0, opacity: 0 } },
634
+ { percentage: 100, properties: { x: 40, opacity: 1 } },
635
+ ],
636
+ },
637
+ } as unknown as GsapAnimation;
638
+
639
+ it("routes both groups of a mixed tween to that tween, not the fallback", () => {
640
+ expect(mixed.propertyGroup).toBeUndefined();
641
+
642
+ expect(resolveAnimIdForProperty("x", [mixed], "fallback")).toBe("mixed-1");
643
+ expect(resolveAnimIdForProperty("opacity", [mixed], "fallback")).toBe("mixed-1");
644
+ });
645
+
646
+ it("falls back only when no tween animates the property's group", () => {
647
+ expect(resolveAnimIdForProperty("rotation", [mixed], "fallback")).toBe("fallback");
648
+ expect(resolveAnimIdForProperty("rotation", [mixed], undefined)).toBe("");
649
+ });
650
+
651
+ it("prefers a single-group tween that owns the lane", () => {
652
+ const opacityOnly = {
653
+ ...mixed,
654
+ id: "opacity-1",
655
+ propertyGroup: "visual",
656
+ keyframes: {
657
+ keyframes: [
658
+ { percentage: 0, properties: { opacity: 0 } },
659
+ { percentage: 100, properties: { opacity: 1 } },
660
+ ],
661
+ },
662
+ } as unknown as GsapAnimation;
663
+
664
+ expect(resolveAnimIdForProperty("opacity", [opacityOnly, mixed], "fallback")).toBe("opacity-1");
665
+ expect(resolveAnimIdForProperty("x", [opacityOnly, mixed], "fallback")).toBe("mixed-1");
666
+ });
667
+ });
@@ -11,6 +11,12 @@ import { LANE_H, getTimelineLaneTop } from "./timelineLayout";
11
11
  import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
12
12
 
13
13
  export interface TimelinePropertyLanesProps {
14
+ /**
15
+ * Id of the wrapper below, so the layer's disclosure caret can point
16
+ * `aria-controls` at the lanes a sighted user sees it reveal. Minted by
17
+ * TimelineLanes, which owns both this subtree and the caret's.
18
+ */
19
+ id?: string;
14
20
  animations: readonly GsapAnimation[];
15
21
  clipStart: number;
16
22
  clipDuration: number;
@@ -29,11 +35,25 @@ export interface TimelinePropertyLanesProps {
29
35
  suppressClickRef?: RefObject<boolean>;
30
36
  }
31
37
 
38
+ /**
39
+ * Keys that ride along in a tween's property bag without being animated: a
40
+ * transform modifier, Studio's internal endpoint marker, and GSAP's reserved
41
+ * `data`. Same exclusion list the parser's classifyTweenPropertyGroup applies —
42
+ * without it `{ x, transformOrigin }` would draw a spurious "Other" lane.
43
+ */
44
+ const NON_ANIMATED_PROPERTIES = new Set(["transformOrigin", "_auto", "data"]);
45
+
46
+ function isAnimatedProperty(property: string): boolean {
47
+ return !NON_ANIMATED_PROPERTIES.has(property);
48
+ }
49
+
32
50
  function hasGroupProperty(
33
51
  properties: Record<string, number | string>,
34
52
  group: PropertyGroupName,
35
53
  ): boolean {
36
- return Object.keys(properties).some((property) => classifyPropertyGroup(property) === group);
54
+ return Object.keys(properties).some(
55
+ (property) => isAnimatedProperty(property) && classifyPropertyGroup(property) === group,
56
+ );
37
57
  }
38
58
 
39
59
  /** The tween's editable keyframes: its real keyframes, or the start→end pair
@@ -42,19 +62,63 @@ function animationKeyframes(animation: GsapAnimation) {
42
62
  return animation.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(animation)?.keyframes ?? [];
43
63
  }
44
64
 
45
- /** A tween contributes a property lane when it has a group and at least one
46
- * editable keyframe (real or synthesized). */
65
+ /**
66
+ * Every property group a tween draws a lane for, classified PER PROPERTY.
67
+ * `animation.propertyGroup` is the parser's whole-tween verdict and is
68
+ * `undefined` for anything spanning more than one group — but `{ x, opacity }`
69
+ * is the canonical HyperFrames entrance tween, and reading that verdict gave it
70
+ * no caret, no reserved row and no diamonds. classifyPropertyGroup is total, so
71
+ * an unrecognised property still lands in "other" rather than vanishing.
72
+ *
73
+ * Single owner: the rendered lanes (sourceGroups) and the reserved row heights
74
+ * (computeLaneCounts) both count groups through here, or they drift.
75
+ */
76
+ export function animationLaneGroups(animation: GsapAnimation): PropertyGroupName[] {
77
+ const groups = new Set<PropertyGroupName>();
78
+ for (const keyframe of animationKeyframes(animation)) {
79
+ for (const property of Object.keys(keyframe.properties)) {
80
+ if (isAnimatedProperty(property)) groups.add(classifyPropertyGroup(property));
81
+ }
82
+ }
83
+ return Array.from(groups);
84
+ }
85
+
86
+ /**
87
+ * Which tween a panel edit to `prop` belongs to.
88
+ *
89
+ * Matches on the groups the tween's KEYFRAMES animate, not on the parser's
90
+ * whole-tween `propertyGroup` verdict: that field is undefined for a legacy
91
+ * mixed tween such as `{ x, opacity }`, so matching it dropped every such
92
+ * tween and sent the edit to the selection's default animation instead, which
93
+ * is a different tween than the lane the user is looking at.
94
+ * {@link animationLaneGroups} is the single owner the rendered lanes count
95
+ * groups through, so resolving here through the same helper keeps the panel
96
+ * and the lanes on one answer.
97
+ */
98
+ export function resolveAnimIdForProperty(
99
+ prop: string,
100
+ animations: readonly GsapAnimation[] | undefined,
101
+ fallbackAnimId: string | undefined,
102
+ ): string {
103
+ const group = classifyPropertyGroup(prop);
104
+ const groupAnim = animations?.find((a) => animationLaneGroups(a).includes(group));
105
+ return groupAnim?.id ?? fallbackAnimId ?? "";
106
+ }
107
+
108
+ /** A tween contributes a property lane when it animates at least one property
109
+ * on at least one editable keyframe (real or synthesized). */
47
110
  export function animationContributesLane(animation: GsapAnimation): boolean {
48
- return !!animation.propertyGroup && animationKeyframes(animation).length > 0;
111
+ return animationLaneGroups(animation).length > 0;
49
112
  }
50
113
 
51
114
  function sourceGroups(animations: readonly GsapAnimation[]) {
52
115
  const groups = new Map<PropertyGroupName, GsapAnimation[]>();
53
116
  for (const animation of animations) {
54
- if (!animation.propertyGroup || !animationContributesLane(animation)) continue;
55
- const groupAnimations = groups.get(animation.propertyGroup) ?? [];
56
- groupAnimations.push(animation);
57
- groups.set(animation.propertyGroup, groupAnimations);
117
+ for (const group of animationLaneGroups(animation)) {
118
+ const groupAnimations = groups.get(group) ?? [];
119
+ groupAnimations.push(animation);
120
+ groups.set(group, groupAnimations);
121
+ }
58
122
  }
59
123
  return groups;
60
124
  }
@@ -112,6 +176,7 @@ export function getTimelinePropertyLanes(
112
176
  }
113
177
 
114
178
  export function TimelinePropertyLanes({
179
+ id,
115
180
  animations,
116
181
  clipStart,
117
182
  clipDuration,
@@ -148,9 +213,13 @@ export function TimelinePropertyLanes({
148
213
  [lanes],
149
214
  );
150
215
 
151
- if (laneData.length === 0) return null;
216
+ // One STATIC wrapper, never `relative`: a static box establishes no containing
217
+ // block, so every absolutely-positioned lane below still resolves against the
218
+ // track-content div and the rendered geometry is byte-identical to the bare
219
+ // fragment this replaced. It is also rendered when there are no lanes at all
220
+ // (collapsed layer), so `id` stays resolvable in both disclosure states.
152
221
  return (
153
- <>
222
+ <div id={id}>
154
223
  {laneData.map(({ group, keyframesData }, laneIndex) => (
155
224
  <div
156
225
  key={group}
@@ -186,6 +255,6 @@ export function TimelinePropertyLanes({
186
255
  />
187
256
  </div>
188
257
  ))}
189
- </>
258
+ </div>
190
259
  );
191
260
  }