@hyperframes/studio 0.7.75 → 0.7.77

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 (134) hide show
  1. package/dist/assets/{hyperframes-player-BrfzYqX3.js → hyperframes-player-CEggaaxR.js} +1 -1
  2. package/dist/assets/{index-BvGKYVrH.js → index-B1INQNmj.js} +1 -1
  3. package/dist/assets/{index-CjGZkNxS.js → index-B1Tjjdse.js} +1 -1
  4. package/dist/assets/index-Bp4jAYZG.js +428 -0
  5. package/dist/assets/index-CBAfprvx.css +1 -0
  6. package/dist/index.d.ts +64 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +18040 -16293
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +16 -17
  12. package/src/components/EditorShell.tsx +3 -0
  13. package/src/components/TimelineToolbar.test.tsx +48 -3
  14. package/src/components/TimelineToolbar.tsx +89 -36
  15. package/src/components/editor/AnimationCard.test.tsx +18 -75
  16. package/src/components/editor/AnimationCard.tsx +26 -2
  17. package/src/components/editor/GsapAddAnimationControl.tsx +68 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +18 -157
  19. package/src/components/editor/KeyframeEaseList.tsx +5 -1
  20. package/src/components/editor/KeyframeNavigation.tsx +46 -14
  21. package/src/components/editor/KeyframeNavigationDiamond.test.tsx +59 -0
  22. package/src/components/editor/MotionPathOverlay.tsx +32 -10
  23. package/src/components/editor/PropertyPanel.tsx +15 -5
  24. package/src/components/editor/PropertyPanelFlat.tsx +48 -23
  25. package/src/components/editor/gsapAnimationCallbacks.test.ts +122 -0
  26. package/src/components/editor/gsapAnimationCallbacks.ts +108 -4
  27. package/src/components/editor/keyframeRetime.test.ts +25 -22
  28. package/src/components/editor/keyframeRetime.ts +1 -2
  29. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +2 -2
  30. package/src/components/editor/propertyPanelFlatMotionSection.tsx +25 -139
  31. package/src/components/editor/propertyPanelFlatTextSection.test.tsx +32 -16
  32. package/src/components/editor/propertyPanelFlatTextSection.tsx +21 -4
  33. package/src/components/nle/PreviewOverlays.tsx +3 -1
  34. package/src/components/nle/useTimelineEditCallbacks.test.ts +30 -0
  35. package/src/components/nle/useTimelineEditCallbacks.test.tsx +693 -0
  36. package/src/components/nle/useTimelineEditCallbacks.ts +197 -57
  37. package/src/contexts/TimelineEditContext.tsx +1 -1
  38. package/src/hooks/deleteSelectedKeyframes.ts +24 -8
  39. package/src/hooks/gsapDragCommit.ts +2 -2
  40. package/src/hooks/gsapDragPositionCommit.ts +84 -5
  41. package/src/hooks/gsapKeyframeCacheHelpers.test.ts +176 -1
  42. package/src/hooks/gsapKeyframeCacheHelpers.ts +93 -52
  43. package/src/hooks/gsapRuntimeBridge.test.ts +100 -0
  44. package/src/hooks/gsapScriptCommitHelpers.ts +3 -2
  45. package/src/hooks/gsapScriptCommitTypes.ts +2 -0
  46. package/src/hooks/gsapShared.test.ts +109 -1
  47. package/src/hooks/gsapShared.ts +142 -17
  48. package/src/hooks/gsapTweenSynth.test.ts +30 -1
  49. package/src/hooks/gsapTweenSynth.ts +61 -14
  50. package/src/hooks/keyframeCacheAstLoad.ts +181 -0
  51. package/src/hooks/timelineEditingHelpers.test.ts +80 -0
  52. package/src/hooks/timelineMoveAdapter.ts +9 -2
  53. package/src/hooks/useDomEditWiring.ts +2 -2
  54. package/src/hooks/useDomSelection.ts +4 -0
  55. package/src/hooks/useEnableKeyframes.test.ts +122 -0
  56. package/src/hooks/useEnableKeyframes.ts +116 -56
  57. package/src/hooks/useGestureCommit.ts +2 -2
  58. package/src/hooks/useGsapAnimationFetchFallback.ts +1 -1
  59. package/src/hooks/useGsapKeyframeOps.test.tsx +92 -13
  60. package/src/hooks/useGsapKeyframeOps.ts +30 -13
  61. package/src/hooks/useGsapScriptCommits.test.tsx +21 -0
  62. package/src/hooks/useGsapScriptCommits.ts +2 -0
  63. package/src/hooks/useGsapSelectionHandlers.test.tsx +69 -3
  64. package/src/hooks/useGsapSelectionHandlers.ts +90 -65
  65. package/src/hooks/useGsapTweenCache.test.ts +16 -0
  66. package/src/hooks/useGsapTweenCache.ts +64 -178
  67. package/src/hooks/usePopulateKeyframeCacheForFile.test.tsx +68 -0
  68. package/src/hooks/useStudioContextValue.test.ts +93 -0
  69. package/src/hooks/useStudioContextValue.ts +17 -3
  70. package/src/hooks/useStudioTestHooks.ts +63 -0
  71. package/src/hooks/useTimelineEditing.test.tsx +187 -22
  72. package/src/hooks/useTimelineEditing.ts +43 -32
  73. package/src/hooks/useTimelineEditingTypes.ts +2 -0
  74. package/src/hooks/useTimelineGroupEditing.ts +106 -82
  75. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +69 -0
  76. package/src/player/components/KeyframeDiamondContextMenu.tsx +19 -8
  77. package/src/player/components/LayerDisclosureRow.tsx +76 -0
  78. package/src/player/components/Timeline.test.ts +420 -90
  79. package/src/player/components/Timeline.tsx +80 -78
  80. package/src/player/components/TimelineCanvas.tsx +33 -21
  81. package/src/player/components/TimelineClip.tsx +3 -1
  82. package/src/player/components/TimelineClipDiamonds.test.tsx +540 -16
  83. package/src/player/components/TimelineClipDiamonds.tsx +320 -117
  84. package/src/player/components/TimelineDiamondConnectors.tsx +145 -0
  85. package/src/player/components/TimelineLanes.tsx +153 -79
  86. package/src/player/components/TimelineOverlays.tsx +4 -18
  87. package/src/player/components/TimelinePropertyLanes.test.tsx +450 -0
  88. package/src/player/components/TimelinePropertyLanes.tsx +191 -0
  89. package/src/player/components/TimelineRazorInteraction.tsx +60 -0
  90. package/src/player/components/TimelineRuler.tsx +11 -21
  91. package/src/player/components/TimelineTrackHeader.test.tsx +346 -0
  92. package/src/player/components/TimelineTrackHeader.tsx +363 -0
  93. package/src/player/components/TrackClipCount.tsx +17 -0
  94. package/src/player/components/timelineCallbacks.ts +21 -6
  95. package/src/player/components/timelineClipDragPreview.test.ts +50 -3
  96. package/src/player/components/timelineClipDragPreview.ts +33 -15
  97. package/src/player/components/timelineCollision.ts +4 -13
  98. package/src/player/components/timelineDiamondTypes.ts +120 -0
  99. package/src/player/components/timelineDragDrop.ts +8 -3
  100. package/src/player/components/timelineEditing.test.ts +19 -27
  101. package/src/player/components/timelineEditing.ts +7 -8
  102. package/src/player/components/timelineKeyframeIdentity.test.ts +49 -0
  103. package/src/player/components/timelineKeyframeIdentity.ts +66 -0
  104. package/src/player/components/timelineLayout.test.ts +162 -9
  105. package/src/player/components/timelineLayout.ts +198 -38
  106. package/src/player/components/timelineMarquee.test.ts +76 -21
  107. package/src/player/components/timelineMarquee.ts +17 -14
  108. package/src/player/components/trackHeaderLaneState.ts +121 -0
  109. package/src/player/components/trackHeaderLaneValues.ts +139 -0
  110. package/src/player/components/useAutoExpandKeyframedClips.test.tsx +75 -0
  111. package/src/player/components/useAutoExpandKeyframedClips.ts +50 -0
  112. package/src/player/components/useTimelineClipDrag.ts +4 -1
  113. package/src/player/components/useTimelineGeometry.ts +4 -1
  114. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +108 -0
  115. package/src/player/components/useTimelineKeyframeHandlers.ts +50 -15
  116. package/src/player/components/useTimelinePlayhead.ts +21 -12
  117. package/src/player/components/useTimelineRangeSelection.ts +35 -14
  118. package/src/player/components/useTimelineTrackLayout.test.ts +53 -0
  119. package/src/player/components/useTimelineTrackLayout.ts +162 -0
  120. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  121. package/src/player/hooks/useExpandedTimelineElements.ts +21 -12
  122. package/src/player/lib/timelineElementHelpers.ts +26 -11
  123. package/src/player/store/keyframeSlice.ts +122 -0
  124. package/src/player/store/playerStore.test.ts +25 -0
  125. package/src/player/store/playerStore.ts +7 -43
  126. package/src/telemetry/events.test.ts +9 -3
  127. package/src/telemetry/events.ts +16 -7
  128. package/src/utils/globalTimeCompiler.test.ts +10 -0
  129. package/src/utils/globalTimeCompiler.ts +3 -3
  130. package/dist/assets/index-B37rWXo4.css +0 -1
  131. package/dist/assets/index-DzDajONI.js +0 -428
  132. package/src/player/components/TimelineDragGhost.tsx +0 -59
  133. package/src/utils/keyframeSelection.test.ts +0 -45
  134. package/src/utils/keyframeSelection.ts +0 -29
@@ -1,6 +1,6 @@
1
1
  import { memo } from "react";
2
2
  import type { TimelineTheme } from "./timelineTheme";
3
- import { GUTTER, RULER_H, TRACKS_LEFT_PAD, formatTimelineTickLabel } from "./timelineLayout";
3
+ import { RULER_H, formatTimelineTickLabel } from "./timelineLayout";
4
4
  import { usePlayerStore } from "../store/playerStore";
5
5
  import { secondsToFrame } from "../lib/time";
6
6
  import type { MusicBeatAnalysis } from "@hyperframes/core/beats";
@@ -15,6 +15,7 @@ interface TimelineRulerProps {
15
15
  majorTickInterval: number;
16
16
  theme: TimelineTheme;
17
17
  beatAnalysis?: MusicBeatAnalysis | null;
18
+ contentOrigin: number;
18
19
  }
19
20
 
20
21
  export const TimelineRuler = memo(function TimelineRuler({
@@ -27,6 +28,7 @@ export const TimelineRuler = memo(function TimelineRuler({
27
28
  majorTickInterval,
28
29
  theme,
29
30
  beatAnalysis,
31
+ contentOrigin,
30
32
  }: TimelineRulerProps) {
31
33
  const timeDisplayMode = usePlayerStore((s) => s.timeDisplayMode);
32
34
  const beatTimes = beatAnalysis?.beatTimes ?? [];
@@ -45,7 +47,7 @@ export const TimelineRuler = memo(function TimelineRuler({
45
47
  the ruler's own small ticks mark intervals (no full-height lines). */}
46
48
  <svg
47
49
  className="absolute pointer-events-none"
48
- style={{ left: GUTTER + TRACKS_LEFT_PAD, width: trackContentWidth, zIndex: 0 }}
50
+ style={{ left: contentOrigin, width: trackContentWidth, zIndex: 0 }}
49
51
  height={totalH}
50
52
  >
51
53
  {showBeats &&
@@ -69,35 +71,23 @@ export const TimelineRuler = memo(function TimelineRuler({
69
71
  </svg>
70
72
 
71
73
  {/* Ruler — sticky so the timestamps stay visible while the tracks scroll
72
- vertically. Opaque background (plus the gutter corner block) so clips
74
+ vertically. Opaque background (plus the label-column corner block) so clips
73
75
  scrolling underneath don't bleed through; z-index sits above the track
74
76
  rows and drag overlays but below the playhead (z 100). */}
75
77
  <div
76
78
  className="sticky top-0 flex"
77
- style={{
78
- height: RULER_H,
79
- width: GUTTER + TRACKS_LEFT_PAD + trackContentWidth,
80
- zIndex: 70,
81
- }}
79
+ style={{ height: RULER_H, width: contentOrigin + trackContentWidth, zIndex: 70 }}
82
80
  >
83
81
  <div
84
82
  className="sticky left-0 z-[12] flex-shrink-0"
85
83
  style={{
86
- width: GUTTER,
87
- // Ruler corner uses the panel surface — same as the ruler strip
88
- // itself, and NO right border: the ruler band stays completely
89
- // clean until 00:00 (the header-boundary line belongs to the track
90
- // rows below, not the ruler).
84
+ width: contentOrigin,
85
+ // Ruler corner uses the panel surface — same as the ruler strip itself.
91
86
  background: theme.shellBackground,
92
87
  }}
93
88
  />
94
- {/* Left breathing pad scrolls with the content, so 00:00 starts a
95
- beat right of the gutter (see TRACKS_LEFT_PAD). */}
96
- <div
97
- aria-hidden="true"
98
- className="flex-shrink-0"
99
- style={{ width: TRACKS_LEFT_PAD, background: theme.shellBackground }}
100
- />
89
+ {/* Breathing pad before 00:00 is folded into contentOrigin (see
90
+ Timeline.tsx: GUTTER + TRACKS_LEFT_PAD), so no separate pad div. */}
101
91
  <div
102
92
  className="relative overflow-hidden"
103
93
  style={{
@@ -110,7 +100,7 @@ export const TimelineRuler = memo(function TimelineRuler({
110
100
  >
111
101
  {/* Each 1px tick line is shifted -0.5px so its CENTER sits exactly on
112
102
  t * pps — matching the playhead line, which is also centered on
113
- GUTTER + t * pps (see getTimelinePlayheadLeft). Without the shift
103
+ contentOrigin + t * pps (see getTimelinePlayheadLeft). Without the shift
114
104
  a tick spans [x, x+1) and its center is half a pixel right. */}
115
105
  {minor.map((t) => (
116
106
  <div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps - 0.5 }}>
@@ -0,0 +1,346 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
8
+ import { TimelineTrackHeader } from "./TimelineTrackHeader";
9
+ import { defaultTimelineTheme } from "./timelineTheme";
10
+ import type { TimelineElement } from "../store/playerStore";
11
+ import type { TimelineEditCallbacks } from "./timelineCallbacks";
12
+ import { LABEL_COL_W } from "./timelineLayout";
13
+
14
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
15
+
16
+ afterEach(() => {
17
+ document.body.innerHTML = "";
18
+ });
19
+
20
+ const ELEMENT: TimelineElement = {
21
+ id: "clip-1",
22
+ label: "Hero card",
23
+ tag: "div",
24
+ start: 0,
25
+ duration: 2,
26
+ track: 0,
27
+ };
28
+
29
+ function animation(
30
+ id: string,
31
+ propertyGroup: PropertyGroupName,
32
+ keyframes: Array<{
33
+ percentage: number;
34
+ properties: Record<string, number | string>;
35
+ ease?: string;
36
+ }>,
37
+ ): GsapAnimation {
38
+ return {
39
+ id,
40
+ targetSelector: "#clip-1",
41
+ method: "to",
42
+ position: 0,
43
+ duration: 2,
44
+ properties: {},
45
+ propertyGroup,
46
+ keyframes: { format: "percentage", keyframes },
47
+ };
48
+ }
49
+
50
+ const POSITION = animation("position-tween", "position", [
51
+ { percentage: 0, properties: { x: 0, y: 0 } },
52
+ { percentage: 50, properties: { x: 100, y: 50 } },
53
+ { percentage: 100, properties: { x: 200, y: 100 } },
54
+ ]);
55
+
56
+ const OPACITY = animation("opacity-tween", "visual", [
57
+ { percentage: 0, properties: { opacity: 0 } },
58
+ { percentage: 50, properties: { opacity: 0.5 } },
59
+ { percentage: 100, properties: { opacity: 1 } },
60
+ ]);
61
+
62
+ interface RenderHeaderOptions {
63
+ animations?: GsapAnimation[];
64
+ clipCount?: number;
65
+ currentTime?: number;
66
+ expanded?: boolean;
67
+ onSeek?: (time: number) => void;
68
+ onTogglePropertyGroupKeyframe?: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"];
69
+ }
70
+
71
+ function renderHeader(options: RenderHeaderOptions = {}): {
72
+ host: HTMLDivElement;
73
+ root: Root;
74
+ rerender: (next: RenderHeaderOptions) => void;
75
+ } {
76
+ const host = document.createElement("div");
77
+ document.body.append(host);
78
+ const root = createRoot(host);
79
+ const render = (next: RenderHeaderOptions) => {
80
+ act(() => {
81
+ root.render(
82
+ <TimelineTrackHeader
83
+ trackNumber={0}
84
+ trackLabel="Hero card"
85
+ contentOrigin={LABEL_COL_W}
86
+ keyframeClip={ELEMENT}
87
+ clipCount={next.clipCount ?? 1}
88
+ isExpanded={next.expanded !== false}
89
+ animations={next.animations ?? [POSITION, OPACITY]}
90
+ currentTime={next.currentTime ?? 0}
91
+ isTrackHidden={false}
92
+ isAudioTrack={false}
93
+ theme={defaultTimelineTheme}
94
+ onToggleClipExpanded={vi.fn()}
95
+ onToggleTrackHidden={vi.fn()}
96
+ onTogglePropertyGroupKeyframe={next.onTogglePropertyGroupKeyframe}
97
+ onSeek={next.onSeek}
98
+ />,
99
+ );
100
+ });
101
+ };
102
+ render(options);
103
+ return { host, root, rerender: render };
104
+ }
105
+
106
+ function click(host: HTMLElement, label: string) {
107
+ const button = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
108
+ expect(button).not.toBeNull();
109
+ act(() => button?.click());
110
+ }
111
+
112
+ describe("TimelineTrackHeader", () => {
113
+ // The header shows one clip's lanes, so how many clips the track holds is
114
+ // otherwise invisible from the label column. A single-clip track stays silent.
115
+ it("shows the track's clip count only once the track holds more than one clip", () => {
116
+ const view = renderHeader({ clipCount: 1 });
117
+ expect(view.host.querySelector('[aria-label="1 clips"]')).toBeNull();
118
+
119
+ view.rerender({ clipCount: 3 });
120
+ expect(view.host.querySelector('[aria-label="3 clips"]')?.textContent).toBe("3");
121
+ act(() => view.root.unmount());
122
+ });
123
+
124
+ // The eye acts on the layer, so it has to be reachable without a pointer and
125
+ // in every disclosure state — a hover-gated eye is unusable by keyboard.
126
+ it("keeps the visibility eye mounted whether the layer is expanded or collapsed", () => {
127
+ const view = renderHeader({ expanded: true });
128
+ expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
129
+
130
+ view.rerender({ expanded: false });
131
+ expect(view.host.querySelector('button[aria-label="Hide track 0"]')).not.toBeNull();
132
+ act(() => view.root.unmount());
133
+ });
134
+
135
+ it("adds and removes a keyframe on the explicitly targeted property-group tween", () => {
136
+ const onTogglePropertyGroupKeyframe = vi.fn();
137
+ const view = renderHeader({ currentTime: 0.5, onTogglePropertyGroupKeyframe });
138
+
139
+ click(view.host, "Add Opacity keyframe");
140
+ expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
141
+ ELEMENT,
142
+ expect.objectContaining({
143
+ animationId: "opacity-tween",
144
+ propertyGroup: "visual",
145
+ tweenPercentage: 25,
146
+ properties: { opacity: 0.25 },
147
+ remove: false,
148
+ }),
149
+ );
150
+
151
+ view.rerender({ currentTime: 1, onTogglePropertyGroupKeyframe });
152
+ click(view.host, "Remove Opacity keyframe");
153
+ expect(onTogglePropertyGroupKeyframe).toHaveBeenLastCalledWith(
154
+ ELEMENT,
155
+ expect.objectContaining({
156
+ animationId: "opacity-tween",
157
+ propertyGroup: "visual",
158
+ tweenPercentage: 50,
159
+ properties: { opacity: 0.5 },
160
+ remove: true,
161
+ }),
162
+ );
163
+ expect(onTogglePropertyGroupKeyframe).not.toHaveBeenCalledWith(
164
+ ELEMENT,
165
+ expect.objectContaining({ animationId: "position-tween" }),
166
+ );
167
+ act(() => view.root.unmount());
168
+ });
169
+
170
+ it("seeks only to the selected group's adjacent keyframes", () => {
171
+ const onSeek = vi.fn();
172
+ const view = renderHeader({
173
+ currentTime: 1,
174
+ animations: [
175
+ POSITION,
176
+ animation("opacity-tween", "visual", [
177
+ { percentage: 25, properties: { opacity: 0.25 } },
178
+ { percentage: 50, properties: { opacity: 0.5 } },
179
+ { percentage: 75, properties: { opacity: 0.75 } },
180
+ ]),
181
+ ],
182
+ onSeek,
183
+ });
184
+
185
+ click(view.host, "Next Position keyframe");
186
+ expect(onSeek).toHaveBeenLastCalledWith(2);
187
+ click(view.host, "Previous Position keyframe");
188
+ expect(onSeek).toHaveBeenLastCalledWith(0);
189
+ expect(onSeek).not.toHaveBeenCalledWith(1.5);
190
+ act(() => view.root.unmount());
191
+ });
192
+
193
+ // The lane header sits inside the track row, whose own click handler selects
194
+ // the track. Every control in the label column has to own its click, or
195
+ // seeking to a keyframe also reselects whatever is behind the header.
196
+ it("keeps lane-header control clicks off the ancestor track row", () => {
197
+ const onAncestorClick = vi.fn();
198
+ const view = renderHeader({
199
+ currentTime: 1,
200
+ onSeek: vi.fn(),
201
+ onTogglePropertyGroupKeyframe: vi.fn(),
202
+ });
203
+ // React 18 delegates from the root container, so an ancestor of it is where
204
+ // a leaked click actually shows up.
205
+ document.body.addEventListener("click", onAncestorClick);
206
+
207
+ // Every control in the lane's label column, found by row rather than by
208
+ // label, so a wording change to one button can't silently drop it here.
209
+ const controls = view.host.querySelectorAll<HTMLButtonElement>(
210
+ '[data-property-group="position"] button',
211
+ );
212
+ expect(controls.length).toBeGreaterThanOrEqual(3);
213
+ for (const button of controls) {
214
+ act(() => {
215
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
216
+ });
217
+ }
218
+
219
+ document.body.removeEventListener("click", onAncestorClick);
220
+ expect(onAncestorClick).not.toHaveBeenCalled();
221
+ act(() => view.root.unmount());
222
+ });
223
+
224
+ it("fills the toggle diamond exactly at that group's keyframe", () => {
225
+ const view = renderHeader({ currentTime: 0.5 });
226
+ const positionToggle = view.host.querySelector<HTMLButtonElement>(
227
+ 'button[aria-label="Add Position keyframe"]',
228
+ );
229
+ expect(positionToggle?.textContent).toBe("◇");
230
+
231
+ view.rerender({ currentTime: 1 });
232
+ expect(
233
+ view.host.querySelector<HTMLButtonElement>('button[aria-label="Remove Position keyframe"]')
234
+ ?.textContent,
235
+ ).toBe("◆");
236
+ act(() => view.root.unmount());
237
+ });
238
+
239
+ it("updates formatted group values when the playhead moves", () => {
240
+ const view = renderHeader({ currentTime: 0.5 });
241
+ expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
242
+ "50, 25",
243
+ );
244
+ expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("25%");
245
+
246
+ view.rerender({ currentTime: 1.5 });
247
+ expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
248
+ "150, 75",
249
+ );
250
+ expect(view.host.querySelector('[data-property-group="visual"]')?.textContent).toContain("75%");
251
+ act(() => view.root.unmount());
252
+ });
253
+
254
+ it("samples mid-segment values along the segment's ease, not linearly", () => {
255
+ // GSAP hangs a segment's ease on the keyframe it arrives at, so 0% -> 50%
256
+ // runs power2.in. Half way through that segment power2.in(0.5) = 0.125, so
257
+ // the readout is 12.5/6.25 and NOT the linear 50/25.
258
+ const eased = animation("eased-position", "position", [
259
+ { percentage: 0, properties: { x: 0, y: 0 } },
260
+ { percentage: 50, properties: { x: 100, y: 50 }, ease: "power2.in" },
261
+ { percentage: 100, properties: { x: 200, y: 100 }, ease: "power2.in" },
262
+ ]);
263
+ const onTogglePropertyGroupKeyframe = vi.fn();
264
+ const view = renderHeader({
265
+ animations: [eased],
266
+ currentTime: 0.5,
267
+ onTogglePropertyGroupKeyframe,
268
+ });
269
+
270
+ expect(view.host.querySelector('[data-property-group="position"]')?.textContent).toContain(
271
+ "12.5, 6.25",
272
+ );
273
+
274
+ // The same sampled value is what an added keyframe gets stamped with, so a
275
+ // header insert lands on the existing curve instead of deforming it.
276
+ click(view.host, "Add Position keyframe");
277
+ expect(onTogglePropertyGroupKeyframe).toHaveBeenCalledOnce();
278
+ expect(onTogglePropertyGroupKeyframe.mock.calls[0][1]).toMatchObject({
279
+ properties: { x: 12.5, y: 6.25 },
280
+ });
281
+ act(() => view.root.unmount());
282
+ });
283
+
284
+ it("disables the previous chevron at or before the group's first keyframe", () => {
285
+ const view = renderHeader({ currentTime: 0 });
286
+ const prevAt0 = view.host.querySelector<HTMLButtonElement>(
287
+ 'button[aria-label="Previous Position keyframe"]',
288
+ );
289
+ expect(prevAt0).not.toBeNull();
290
+ expect(prevAt0?.disabled).toBe(true);
291
+
292
+ view.rerender({ currentTime: 1 });
293
+ const prevAt1 = view.host.querySelector<HTMLButtonElement>(
294
+ 'button[aria-label="Previous Position keyframe"]',
295
+ );
296
+ expect(prevAt1?.disabled).toBe(false);
297
+ act(() => view.root.unmount());
298
+ });
299
+
300
+ it("uses the same lane row offsets when collapsed, expanded once, and expanded multiple times", () => {
301
+ const view = renderHeader({ expanded: false });
302
+ expect(view.host.querySelectorAll("[data-timeline-lane-top]")).toHaveLength(0);
303
+
304
+ const assertAligned = (animations: GsapAnimation[]) => {
305
+ view.rerender({ animations });
306
+ const lanesHost = document.createElement("div");
307
+ document.body.append(lanesHost);
308
+ const lanesRoot = createRoot(lanesHost);
309
+ act(() => {
310
+ lanesRoot.render(
311
+ <TimelinePropertyLanes
312
+ animations={animations}
313
+ clipStart={0}
314
+ clipDuration={2}
315
+ clipLeftPx={120}
316
+ clipWidthPx={200}
317
+ accentColor="#3CE6AC"
318
+ isSelected
319
+ currentPercentage={0}
320
+ elementId="clip-1"
321
+ selectedKeyframes={new Set()}
322
+ />,
323
+ );
324
+ });
325
+ expect(
326
+ Array.from(view.host.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
327
+ (row) => row.style.top,
328
+ ),
329
+ ).toEqual(
330
+ Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-lane-top]")).map(
331
+ (row) => row.style.top,
332
+ ),
333
+ );
334
+ expect(
335
+ Array.from(lanesHost.querySelectorAll<HTMLElement>("[data-timeline-property-lane]")).map(
336
+ (row) => row.style.left,
337
+ ),
338
+ ).toEqual(animations.map(() => "120px"));
339
+ act(() => lanesRoot.unmount());
340
+ };
341
+
342
+ assertAligned([POSITION]);
343
+ assertAligned([POSITION, OPACITY]);
344
+ act(() => view.root.unmount());
345
+ });
346
+ });