@hyperframes/studio 0.7.77 → 0.7.79

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 (77) hide show
  1. package/dist/assets/{hyperframes-player-CEggaaxR.js → hyperframes-player-mFah2TZE.js} +1 -1
  2. package/dist/assets/{index-B1Tjjdse.js → index-Bqj3h_1a.js} +1 -1
  3. package/dist/assets/{index-Bp4jAYZG.js → index-DlZMDyYs.js} +194 -194
  4. package/dist/assets/index-gGVKuFg5.css +1 -0
  5. package/dist/assets/{index-B1INQNmj.js → index-hmnoiSEV.js} +1 -1
  6. package/dist/index.html +2 -2
  7. package/dist/index.js +1432 -1181
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioLeftSidebar.tsx +10 -5
  11. package/src/components/StudioRightPanel.tsx +5 -4
  12. package/src/components/TimelineToolbar.tsx +3 -1
  13. package/src/components/editor/KeyframeNavigation.tsx +5 -0
  14. package/src/components/editor/MotionPathOverlay.tsx +4 -1
  15. package/src/components/editor/PropertyPanel.tsx +3 -7
  16. package/src/components/editor/keyframeRetime.test.ts +42 -0
  17. package/src/components/editor/keyframeRetime.ts +4 -1
  18. package/src/components/editor/motionPathSelection.test.ts +65 -0
  19. package/src/components/editor/motionPathSelection.ts +13 -3
  20. package/src/components/editor/propertyPanelColor.test.tsx +181 -12
  21. package/src/components/editor/propertyPanelColor.tsx +32 -26
  22. package/src/components/editor/propertyPanelPrimitives.tsx +3 -1
  23. package/src/components/nle/TimelineResizeDivider.tsx +9 -4
  24. package/src/components/pointerTargetSize.test.tsx +65 -0
  25. package/src/components/sidebar/CompositionsTab.drag.test.tsx +41 -0
  26. package/src/components/sidebar/CompositionsTab.tsx +70 -33
  27. package/src/hooks/gsapDragCommit.ts +43 -10
  28. package/src/hooks/gsapDragStaticSetHelpers.ts +16 -6
  29. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +63 -0
  30. package/src/hooks/gsapKeyframeCacheHelpers.ts +45 -38
  31. package/src/hooks/gsapKeyframeCommit.ts +11 -2
  32. package/src/hooks/gsapResizeIntercept.ts +2 -2
  33. package/src/hooks/gsapRuntimeBridge.ts +3 -2
  34. package/src/hooks/gsapScriptCommitHelpers.ts +17 -2
  35. package/src/hooks/gsapShared.test.ts +144 -0
  36. package/src/hooks/gsapShared.ts +264 -0
  37. package/src/hooks/gsapShared.writeTarget.test.ts +289 -0
  38. package/src/hooks/keyframeCacheAstLoad.ts +5 -71
  39. package/src/hooks/newTweenTarget.test.ts +364 -0
  40. package/src/hooks/newTweenTargetHooks.test.tsx +255 -0
  41. package/src/hooks/timelineTrackVisibility.test.ts +61 -1
  42. package/src/hooks/timelineTrackVisibility.ts +11 -1
  43. package/src/hooks/useAnimatedPropertyCommit.ts +24 -8
  44. package/src/hooks/useEnableKeyframes.test.ts +44 -0
  45. package/src/hooks/useEnableKeyframes.ts +18 -5
  46. package/src/hooks/useGestureCommit.ts +21 -4
  47. package/src/hooks/useGsapScriptCommits.ts +3 -0
  48. package/src/hooks/useGsapTweenCache.test.ts +11 -3
  49. package/src/hooks/useGsapTweenCache.ts +4 -11
  50. package/src/player/components/LayerDisclosureRow.tsx +4 -1
  51. package/src/player/components/Player.test.ts +143 -12
  52. package/src/player/components/Player.tsx +84 -43
  53. package/src/player/components/ShortcutsPanel.test.tsx +158 -0
  54. package/src/player/components/ShortcutsPanel.tsx +11 -15
  55. package/src/player/components/Timeline.test.ts +3 -1
  56. package/src/player/components/TimelineCanvas.tsx +2 -1
  57. package/src/player/components/TimelineClipDiamonds.test.tsx +17 -2
  58. package/src/player/components/TimelineClipDiamonds.tsx +11 -1
  59. package/src/player/components/TimelineDiamondConnectors.tsx +14 -1
  60. package/src/player/components/TimelineLanes.test.tsx +281 -0
  61. package/src/player/components/TimelineLanes.tsx +43 -97
  62. package/src/player/components/TimelinePropertyLanes.test.tsx +217 -0
  63. package/src/player/components/TimelinePropertyLanes.tsx +80 -11
  64. package/src/player/components/TimelineTrackHeader.test.tsx +75 -5
  65. package/src/player/components/TimelineTrackHeader.tsx +52 -22
  66. package/src/player/components/timelineLaneProps.ts +86 -0
  67. package/src/player/components/timelineTrackDisplay.test.ts +35 -0
  68. package/src/player/components/timelineTrackDisplay.ts +38 -0
  69. package/src/player/components/useTimelineRangeSelection.ts +5 -0
  70. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +127 -0
  71. package/src/player/components/useTimelineTrackLayout.test.ts +35 -0
  72. package/src/player/components/useTimelineTrackLayout.ts +4 -4
  73. package/src/player/hooks/useExpandedTimelineElements.test.ts +151 -2
  74. package/src/player/hooks/useExpandedTimelineElements.ts +46 -7
  75. package/src/telemetry/client.test.ts +12 -3
  76. package/src/telemetry/client.ts +3 -2
  77. package/dist/assets/index-CBAfprvx.css +0 -1
@@ -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
  }
@@ -60,12 +60,14 @@ const OPACITY = animation("opacity-tween", "visual", [
60
60
  ]);
61
61
 
62
62
  interface RenderHeaderOptions {
63
+ keyframeClip?: TimelineElement;
63
64
  animations?: GsapAnimation[];
64
65
  clipCount?: number;
65
66
  currentTime?: number;
66
67
  expanded?: boolean;
67
68
  onSeek?: (time: number) => void;
68
69
  onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
70
+ onToggleTrackHidden?: TimelineEditCallbacks["onToggleTrackHidden"];
69
71
  }
70
72
 
71
73
  function renderHeader(options: RenderHeaderOptions = {}): {
@@ -80,10 +82,14 @@ function renderHeader(options: RenderHeaderOptions = {}): {
80
82
  act(() => {
81
83
  root.render(
82
84
  <TimelineTrackHeader
83
- trackNumber={0}
85
+ // A real fractional z-order sort key, so a label built from it would
86
+ // read out "track 0.16666666666666666".
87
+ trackNumber={1 / 6}
88
+ trackDisplayNumber={1}
84
89
  trackLabel="Hero card"
90
+ lanesId="timeline-lanes-track-0"
85
91
  contentOrigin={LABEL_COL_W}
86
- keyframeClip={ELEMENT}
92
+ keyframeClip={next.keyframeClip ?? ELEMENT}
87
93
  clipCount={next.clipCount ?? 1}
88
94
  isExpanded={next.expanded !== false}
89
95
  animations={next.animations ?? [POSITION, OPACITY]}
@@ -92,7 +98,7 @@ function renderHeader(options: RenderHeaderOptions = {}): {
92
98
  isAudioTrack={false}
93
99
  theme={defaultTimelineTheme}
94
100
  onToggleClipExpanded={vi.fn()}
95
- onToggleTrackHidden={vi.fn()}
101
+ onToggleTrackHidden={next.onToggleTrackHidden ?? vi.fn()}
96
102
  onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
97
103
  onSeek={next.onSeek}
98
104
  />,
@@ -110,6 +116,53 @@ function click(host: HTMLElement, label: string) {
110
116
  }
111
117
 
112
118
  describe("TimelineTrackHeader", () => {
119
+ // An expanded sub-composition child sits on the MASTER timeline at a
120
+ // host-absolute start, but its tweens are parsed from its own file and are
121
+ // local to it. Feeding the raw start straight into the clip-% math put every
122
+ // lane keyframe far outside the clip.
123
+ it("keeps an expanded sub-comp child's lane percentages inside the clip", () => {
124
+ const child: TimelineElement = {
125
+ id: "pill",
126
+ tag: "div",
127
+ start: 16.5,
128
+ duration: 2,
129
+ track: 0,
130
+ expandedParentStart: 16,
131
+ sourceFile: "scene.html",
132
+ };
133
+ const local: GsapAnimation = {
134
+ id: "pill-tween",
135
+ targetSelector: "#pill",
136
+ method: "to",
137
+ position: 0.5,
138
+ resolvedStart: 0.5,
139
+ duration: 2,
140
+ properties: {},
141
+ propertyGroup: "position",
142
+ keyframes: {
143
+ format: "percentage",
144
+ keyframes: [
145
+ { percentage: 0, properties: { x: 0 } },
146
+ { percentage: 100, properties: { x: 100 } },
147
+ ],
148
+ },
149
+ };
150
+ // Playhead at the clip's midpoint (master time), so the 100% keyframe is
151
+ // ahead of it. On the raw host-absolute basis every keyframe rebased to a
152
+ // large negative percentage and nothing was ever ahead of the playhead.
153
+ const view = renderHeader({
154
+ keyframeClip: child,
155
+ animations: [local],
156
+ currentTime: 17.5,
157
+ });
158
+
159
+ expect(
160
+ view.host.querySelector<HTMLButtonElement>('button[aria-label="Next Position keyframe"]')
161
+ ?.disabled,
162
+ ).toBe(false);
163
+ act(() => view.root.unmount());
164
+ });
165
+
113
166
  // The header shows one clip's lanes, so how many clips the track holds is
114
167
  // otherwise invisible from the label column. A single-clip track stays silent.
115
168
  it("shows the track's clip count only once the track holds more than one clip", () => {
@@ -125,10 +178,27 @@ describe("TimelineTrackHeader", () => {
125
178
  // in every disclosure state — a hover-gated eye is unusable by keyboard.
126
179
  it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
127
180
  const view = renderHeader({ expanded: true });
128
- expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
181
+ expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
129
182
 
130
183
  view.rerender({ expanded: false });
131
- expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
184
+ expect(view.host.querySelector('button[aria-label="Hide track 1"]')).not.toBeNull();
185
+ act(() => view.root.unmount());
186
+ });
187
+
188
+ // trackNumber is a fractional z-order sort key, so building the label from it
189
+ // made screen readers announce "Hide track 0.16666666666666666". The display
190
+ // number is label-only; the toggle still routes by the real key.
191
+ it("announces the display track number but toggles with the real fractional key", () => {
192
+ const onToggleTrackHidden = vi.fn();
193
+ const view = renderHeader({ onToggleTrackHidden });
194
+ const eye = view.host.querySelector<HTMLButtonElement>('button[aria-label="Hide track 1"]');
195
+
196
+ expect(eye).not.toBeNull();
197
+ expect(eye?.title).toBe("Hide track 1");
198
+ expect(view.host.innerHTML).not.toContain("0.16666666666666666");
199
+
200
+ act(() => eye?.click());
201
+ expect(onToggleTrackHidden).toHaveBeenCalledWith(1 / 6, true);
132
202
  act(() => view.root.unmount());
133
203
  });
134
204
 
@@ -4,6 +4,7 @@ import { Music } from "../../icons/SystemIcons";
4
4
  import type { TimelineElement } from "../store/playerStore";
5
5
  import type { TimelineEditCallbacks } from "./timelineCallbacks";
6
6
  import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
7
+ import { clipTimingStart } from "../../hooks/gsapShared";
7
8
  import { LayerDisclosureRow } from "./LayerDisclosureRow";
8
9
  import { TrackClipCount } from "./TrackClipCount";
9
10
  import { LABEL_COL_W, LANE_H, getTimelineLaneTop } from "./timelineLayout";
@@ -14,10 +15,21 @@ import {
14
15
  type TimelinePropertyLane,
15
16
  } from "./trackHeaderLaneState";
16
17
  import { valueReadout } from "./trackHeaderLaneValues";
18
+ import { trackDisplaySuffix } from "./timelineTrackDisplay";
17
19
 
18
20
  interface TimelineTrackHeaderProps {
21
+ /** The track's real key: a FRACTIONAL z-order sort value. Routes callbacks;
22
+ * never shown or announced. */
19
23
  trackNumber: number;
24
+ /** The track's 1-based position in the rendered order: the only number safe
25
+ * to put in a label. Announcing `trackNumber` read out "track
26
+ * 0.16666666666666666". Null when the key has no row, which drops the number
27
+ * from the label rather than inventing one (see trackDisplayNumber). */
28
+ trackDisplayNumber: number | null;
20
29
  trackLabel: string;
30
+ /** Id of the canvas-side lanes element the disclosure caret expands. Minted by
31
+ * TimelineLanes, which is the one place that sees both subtrees. */
32
+ lanesId: string;
21
33
  contentOrigin: number;
22
34
  /** The track's active keyframe clip (selected, else primary) — the one whose
23
35
  * disclosure + property rows this header shows, whether expanded or not. */
@@ -39,16 +51,21 @@ interface TimelineTrackHeaderProps {
39
51
  function VisibilityButton({
40
52
  hidden,
41
53
  trackNumber,
54
+ trackDisplayNumber,
42
55
  visible,
43
56
  onToggle,
44
57
  }: {
45
58
  hidden: boolean;
46
59
  trackNumber: number;
60
+ trackDisplayNumber: number | null;
47
61
  visible: boolean;
48
62
  onToggle: TimelineEditCallbacks["onToggleTrackHidden"];
49
63
  }) {
50
64
  if (!visible) return <span aria-hidden="true" className="h-6 w-6 shrink-0" />;
51
- const label = hidden ? `Show track ${trackNumber}` : `Hide track ${trackNumber}`;
65
+ // Display number in the text, real key in the callback. The two must not be
66
+ // conflated in either direction.
67
+ const suffix = trackDisplaySuffix(trackDisplayNumber);
68
+ const label = hidden ? `Show track${suffix}` : `Hide track${suffix}`;
52
69
  return (
53
70
  <button
54
71
  type="button"
@@ -76,6 +93,7 @@ function VisibilityButton({
76
93
  // count, eye. Not deprecated — it is the live path for every track without lanes.
77
94
  function PlainTrackHeader({
78
95
  trackNumber,
96
+ trackDisplayNumber,
79
97
  trackLabel,
80
98
  clipCount,
81
99
  showTrackLabel,
@@ -85,6 +103,7 @@ function PlainTrackHeader({
85
103
  }: Pick<
86
104
  TimelineTrackHeaderProps,
87
105
  | "trackNumber"
106
+ | "trackDisplayNumber"
88
107
  | "trackLabel"
89
108
  | "clipCount"
90
109
  | "isTrackHidden"
@@ -105,6 +124,7 @@ function PlainTrackHeader({
105
124
  <VisibilityButton
106
125
  hidden={isTrackHidden}
107
126
  trackNumber={trackNumber}
127
+ trackDisplayNumber={trackDisplayNumber}
108
128
  visible
109
129
  onToggle={onToggleTrackHidden}
110
130
  />
@@ -262,7 +282,9 @@ function PropertyGroupHeaderRow({
262
282
 
263
283
  export function TimelineTrackHeader({
264
284
  trackNumber,
285
+ trackDisplayNumber,
265
286
  trackLabel,
287
+ lanesId,
266
288
  contentOrigin,
267
289
  keyframeClip,
268
290
  clipCount,
@@ -281,13 +303,14 @@ export function TimelineTrackHeader({
281
303
  ? ((currentTime - keyframeClip.start) / keyframeClip.duration) * 100
282
304
  : 0;
283
305
  const lanes = keyframeClip
284
- ? getTimelinePropertyLanes(animations, keyframeClip.start, keyframeClip.duration)
306
+ ? // clipTimingStart, not the raw start: an expanded sub-comp child's start is
307
+ // host-absolute while its tweens are local to its own file.
308
+ getTimelinePropertyLanes(animations, clipTimingStart(keyframeClip), keyframeClip.duration)
285
309
  : [];
286
310
  // Label mode = keyframe view; the label column stays LABEL_COL_W (Timeline.tsx
287
311
  // owns the gutter past it, so a 0% diamond isn't clipped by this panel).
288
312
  const showTrackLabel = contentOrigin >= LABEL_COL_W;
289
313
  const isKeyframeLayer = !!keyframeClip && lanes.length > 0;
290
- const lanesId = `timeline-lanes-track-${trackNumber}`;
291
314
 
292
315
  return (
293
316
  <div
@@ -307,6 +330,7 @@ export function TimelineTrackHeader({
307
330
  {!keyframeClip || lanes.length === 0 ? (
308
331
  <PlainTrackHeader
309
332
  trackNumber={trackNumber}
333
+ trackDisplayNumber={trackDisplayNumber}
310
334
  trackLabel={trackLabel}
311
335
  clipCount={clipCount}
312
336
  showTrackLabel={showTrackLabel}
@@ -333,29 +357,35 @@ export function TimelineTrackHeader({
333
357
  <VisibilityButton
334
358
  hidden={isTrackHidden}
335
359
  trackNumber={trackNumber}
360
+ trackDisplayNumber={trackDisplayNumber}
336
361
  visible
337
362
  onToggle={onToggleTrackHidden}
338
363
  />
339
364
  </LayerDisclosureRow>
340
- {/* Always mounted so the caret's aria-controls resolves in both states. */}
341
- <div id={lanesId}>
342
- {isExpanded &&
343
- lanes.map((lane, laneIndex) => (
344
- <PropertyGroupHeaderRow
345
- key={lane.group}
346
- lane={lane}
347
- laneIndex={laneIndex}
348
- isLastLane={laneIndex === lanes.length - 1}
349
- expandedElement={keyframeClip}
350
- currentTime={currentTime}
351
- clipPercentage={clipPercentage}
352
- gutterBackground={theme.gutterBackground}
353
- columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
354
- onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
355
- onSeek={onSeek}
356
- />
357
- ))}
358
- </div>
365
+ {/* The caret expands TWO disjoint subtrees: these label-column rows,
366
+ which carry the per-lane keyframe controls, and the diamond lanes
367
+ on the canvas. `lanesId` names the canvas lanes (rendered by
368
+ TimelineLanes), because that is what a sighted user watches appear
369
+ and what following the reference has to land on. These rows are not
370
+ empty and are not the target; they are absolutely positioned inside
371
+ the sticky column, which is what made a wrapper HERE compute to
372
+ 0x0 and hold no diamonds. */}
373
+ {isExpanded &&
374
+ lanes.map((lane, laneIndex) => (
375
+ <PropertyGroupHeaderRow
376
+ key={lane.group}
377
+ lane={lane}
378
+ laneIndex={laneIndex}
379
+ isLastLane={laneIndex === lanes.length - 1}
380
+ expandedElement={keyframeClip}
381
+ currentTime={currentTime}
382
+ clipPercentage={clipPercentage}
383
+ gutterBackground={theme.gutterBackground}
384
+ columnWidth={showTrackLabel ? LABEL_COL_W : contentOrigin}
385
+ onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
386
+ onSeek={onSeek}
387
+ />
388
+ ))}
359
389
  </>
360
390
  )}
361
391
  </div>