@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
@@ -0,0 +1,127 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // Regression guard for the ruler-click seek. `handlePointerUp` replays
4
+ // `pendingClientXRef`, which only `updateScrubDrag` (pointermove) used to write,
5
+ // so a press with no move settled on the ref's initial 0 and clamped the
6
+ // playhead to t=0 — silently discarding the correct pointerdown seek.
7
+ import React, { act } from "react";
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { mountReactHarness } from "../../hooks/domSelectionTestHarness";
10
+ import { useTimelineRangeSelection } from "./useTimelineRangeSelection";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ /** Scroll container top; a press within RULER_H of this counts as a ruler press. */
15
+ const SCROLL_TOP = 100;
16
+ /** Comfortably inside the ruler band (RULER_H is 28 at time of writing). */
17
+ const RULER_Y = SCROLL_TOP + 4;
18
+
19
+ type Handlers = ReturnType<typeof useTimelineRangeSelection>;
20
+
21
+ function makeScrollEl(): HTMLDivElement {
22
+ const el = document.createElement("div");
23
+ el.getBoundingClientRect = () =>
24
+ ({ top: SCROLL_TOP, left: 0, width: 1000, height: 400 }) as DOMRect;
25
+ return el;
26
+ }
27
+
28
+ function makePointerEvent(clientX: number, clientY: number): React.PointerEvent {
29
+ const currentTarget = document.createElement("div");
30
+ currentTarget.setPointerCapture = vi.fn();
31
+ return {
32
+ button: 0,
33
+ shiftKey: false,
34
+ metaKey: false,
35
+ ctrlKey: false,
36
+ pointerId: 1,
37
+ clientX,
38
+ clientY,
39
+ target: document.createElement("div"),
40
+ currentTarget,
41
+ } as unknown as React.PointerEvent;
42
+ }
43
+
44
+ const roots: Array<{ unmount: () => void }> = [];
45
+
46
+ afterEach(() => {
47
+ for (const root of roots.splice(0)) act(() => root.unmount());
48
+ });
49
+
50
+ function setup(): { handlers: () => Handlers; seekFromX: ReturnType<typeof vi.fn> } {
51
+ const seekFromX = vi.fn();
52
+ const scrollEl = makeScrollEl();
53
+ let latest: Handlers | null = null;
54
+
55
+ // Hoisted so they survive re-renders. The hook mutates `isDragging.current`
56
+ // across the press, and a fresh object per render would reset it to false.
57
+ const scrollRef = { current: scrollEl };
58
+ const ppsRef = { current: 25 };
59
+ const dragScrollRaf = { current: 0 };
60
+ const isDragging = { current: false };
61
+ const elementsRef = { current: [] };
62
+ const trackOrderRef = { current: [] };
63
+ const rowHeightsRef = { current: [] };
64
+
65
+ function Probe(): null {
66
+ latest = useTimelineRangeSelection({
67
+ scrollRef,
68
+ ppsRef,
69
+ effectiveDuration: 30,
70
+ pps: 25,
71
+ seekFromX,
72
+ autoScrollDuringDrag: vi.fn(),
73
+ dragScrollRaf,
74
+ isDragging,
75
+ setShowPopover: vi.fn(),
76
+ elementsRef,
77
+ trackOrderRef,
78
+ rowHeightsRef,
79
+ contentOrigin: 0,
80
+ });
81
+ return null;
82
+ }
83
+
84
+ roots.push(mountReactHarness(<Probe />));
85
+ return {
86
+ handlers: () => {
87
+ if (!latest) throw new Error("hook did not render");
88
+ return latest;
89
+ },
90
+ seekFromX,
91
+ };
92
+ }
93
+
94
+ describe("useTimelineRangeSelection — ruler press seek", () => {
95
+ it("replays the pressed x on pointerup when the pointer never moved", () => {
96
+ const { handlers, seekFromX } = setup();
97
+
98
+ act(() => handlers().handlePointerDown(makePointerEvent(1000, RULER_Y)));
99
+ act(() => handlers().handlePointerUp());
100
+
101
+ // Both the press and the settle must land on the SAME x. The bug settled on
102
+ // 0, so the LAST call is the one that decides where the playhead ends up.
103
+ expect(seekFromX).toHaveBeenCalledWith(1000);
104
+ expect(seekFromX).not.toHaveBeenCalledWith(0);
105
+ expect(seekFromX.mock.calls.at(-1)).toEqual([1000]);
106
+ });
107
+
108
+ it("does not fall back to x=0 for a press nearer the left edge either", () => {
109
+ const { handlers, seekFromX } = setup();
110
+
111
+ act(() => handlers().handlePointerDown(makePointerEvent(400, RULER_Y)));
112
+ act(() => handlers().handlePointerUp());
113
+
114
+ expect(seekFromX.mock.calls.at(-1)).toEqual([400]);
115
+ });
116
+
117
+ it("settles on the final pointermove x when the pointer did move", () => {
118
+ const { handlers, seekFromX } = setup();
119
+
120
+ act(() => handlers().handlePointerDown(makePointerEvent(700, RULER_Y)));
121
+ act(() => handlers().handlePointerMove(makePointerEvent(760, RULER_Y)));
122
+ act(() => handlers().handlePointerUp());
123
+
124
+ // The move must win over the seeded press coordinate.
125
+ expect(seekFromX.mock.calls.at(-1)).toEqual([760]);
126
+ });
127
+ });
@@ -6,6 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
6
6
  import { afterEach, describe, expect, it } from "vitest";
7
7
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
8
8
  import { LANE_H, TRACK_H } from "./timelineLayout";
9
+ import { getTimelinePropertyLanes } from "./TimelinePropertyLanes";
9
10
  import { useTimelineTrackLayout } from "./useTimelineTrackLayout";
10
11
 
11
12
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
@@ -50,4 +51,38 @@ describe("useTimelineTrackLayout", () => {
50
51
  expect(layout?.rowHeights).toEqual([TRACK_H + LANE_H]);
51
52
  act(() => root.unmount());
52
53
  });
54
+
55
+ // The row height reserved here and the lanes actually rendered are two
56
+ // readings of the same question. They used to be two inline copies of the
57
+ // group-set rule, and a mixed-group tween made them disagree: zero reserved
58
+ // rows under two rendered lanes.
59
+ it("reserves exactly as many rows as the lanes a mixed-group tween renders", () => {
60
+ const elements: TimelineElement[] = [
61
+ { id: "clip-1", tag: "div", start: 0, duration: 1, track: 0 },
62
+ ];
63
+ const mixed: GsapAnimation = {
64
+ id: "entrance",
65
+ targetSelector: "#clip-1",
66
+ method: "to",
67
+ position: 0,
68
+ duration: 1,
69
+ properties: { x: 420, opacity: 1 },
70
+ };
71
+ const animations = new Map<string, GsapAnimation[]>([["clip-1", [mixed]]]);
72
+ usePlayerStore.setState({ expandedClipIds: new Set(["clip-1"]) });
73
+
74
+ let layout: ReturnType<typeof useTimelineTrackLayout> | undefined;
75
+ function Probe() {
76
+ layout = useTimelineTrackLayout(elements, animations, null, new Set());
77
+ return null;
78
+ }
79
+
80
+ const root = createRoot(document.createElement("div"));
81
+ act(() => root.render(React.createElement(Probe)));
82
+
83
+ expect(getTimelinePropertyLanes([mixed], 0, 1)).toHaveLength(2);
84
+ expect(layout?.laneCounts.get("clip-1")).toBe(2);
85
+ expect(layout?.rowHeights).toEqual([TRACK_H + 2 * LANE_H]);
86
+ act(() => root.unmount());
87
+ });
53
88
  });
@@ -1,6 +1,6 @@
1
1
  import { useMemo, useRef } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
- import { animationContributesLane } from "./TimelinePropertyLanes";
3
+ import { animationLaneGroups } from "./TimelinePropertyLanes";
4
4
  import { usePlayerStore, type TimelineElement } from "../store/playerStore";
5
5
  import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability";
6
6
  import type { DraggedClipState } from "./timelineClipDragTypes";
@@ -56,9 +56,9 @@ function computeLaneCounts(
56
56
  const clipId = element.key ?? element.id;
57
57
  const propertyGroups = new Set<string>();
58
58
  for (const animation of gsapAnimations.get(clipId) ?? []) {
59
- if (animation.propertyGroup && animationContributesLane(animation)) {
60
- propertyGroups.add(animation.propertyGroup);
61
- }
59
+ // Same helper the rendered lanes count through, so a reserved row and a
60
+ // drawn lane can never disagree.
61
+ for (const group of animationLaneGroups(animation)) propertyGroups.add(group);
62
62
  }
63
63
  laneCounts.set(clipId, propertyGroups.size);
64
64
  }
@@ -143,6 +143,51 @@ describe("buildExpandedElements", () => {
143
143
  expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
144
144
  });
145
145
 
146
+ it("keeps the middle host's row when drilling two levels deep", () => {
147
+ // A embeds B; C lives in B. Drilling into B must leave BOTH host rows
148
+ // standing: sparing only the top-level one drops B's row, and its keyframe
149
+ // lane goes with it because diamonds render per row.
150
+ const elements = [
151
+ el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
152
+ el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }),
153
+ ];
154
+ const manifest = [
155
+ clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
156
+ clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
157
+ clip({ id: "C", start: 13, duration: 2 }),
158
+ ];
159
+ const parentMap = new Map([
160
+ ["B", "A"],
161
+ ["C", "B"],
162
+ ]);
163
+
164
+ const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
165
+ const rows = out.map((e) => e.domId ?? e.id);
166
+ expect(rows).toContain("B");
167
+ // The child sits under its own host, not under the top-level row.
168
+ expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B"));
169
+ });
170
+
171
+ it("still drills a host that exists only in the manifest, without a row for it", () => {
172
+ // Same shape, but B has no store element, so there is no row to spare. The
173
+ // children stay anchored to the top-level row rather than vanishing.
174
+ const elements = [
175
+ el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
176
+ ];
177
+ const manifest = [
178
+ clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
179
+ clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
180
+ clip({ id: "C", start: 13, duration: 2 }),
181
+ ];
182
+ const parentMap = new Map([
183
+ ["B", "A"],
184
+ ["C", "B"],
185
+ ]);
186
+
187
+ const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
188
+ expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]);
189
+ });
190
+
146
191
  // Regression: an expanded child must share one identity (`key`) with the flat
147
192
  // store element for the same DOM id. Before the fix the child key fell back to
148
193
  // the colon form (`index.html:eyebrow:N`) while the store/selection used the
@@ -176,6 +221,35 @@ describe("buildExpandedElements", () => {
176
221
  expect(child.key).toBe(expectedStoreKey);
177
222
  });
178
223
 
224
+ // Regression: a child row is built from a manifest clip, which carries none of
225
+ // the host element's attributes. Reading hidden off the manifest left every
226
+ // expanded row reporting itself visible, so the eye wrote data-hidden a second
227
+ // time instead of removing it and the element could never be shown again.
228
+ it("inherits hidden and locked state from the flat store element", () => {
229
+ const elements = [
230
+ el({ id: "s1", domId: "s1", start: 0, duration: 14 }),
231
+ el({
232
+ id: "eyebrow",
233
+ key: "index.html#eyebrow",
234
+ domId: "eyebrow",
235
+ start: 0,
236
+ duration: 14,
237
+ hidden: true,
238
+ timelineLocked: true,
239
+ }),
240
+ ];
241
+ const manifest = [
242
+ clip({ id: "s1", start: 0, duration: 14 }),
243
+ clip({ id: "eyebrow", start: 0, duration: 14 }),
244
+ ];
245
+ const parentMap = new Map([["eyebrow", "s1"]]);
246
+
247
+ const out = buildExpandedElements(elements, manifest, parentMap, "s1", "s1");
248
+ const child = out.find((e) => e.domId === "eyebrow")!;
249
+ expect(child.hidden).toBe(true);
250
+ expect(child.timelineLocked).toBe(true);
251
+ });
252
+
179
253
  // Sub-comp internals (group + pills) have no data-start, so they're not in the
180
254
  // manifest. They arrive as DOM children and must still expand under their host.
181
255
  it("expands DOM-only sub-comp children (no manifest clip) under the host", () => {
@@ -240,8 +314,112 @@ describe("buildExpandedElements", () => {
240
314
  expect(pills[0]!.duration).toBe(6);
241
315
  expect(pills[0]!.sourceFile).toBe("scene.html");
242
316
  expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
243
- // The host row is replaced by its children.
244
- expect(out.some((e) => e.domId === "scene-host")).toBe(false);
317
+ // The host row survives the expansion; its children are added under it.
318
+ expect(out.some((e) => e.id === "scene-host")).toBe(true);
319
+ });
320
+
321
+ // fallow-ignore-next-line code-duplication
322
+ it("keeps the host row and appends its children directly below it", () => {
323
+ const elements = [
324
+ el({
325
+ id: "s3",
326
+ domId: "s3",
327
+ key: "index.html#s3",
328
+ start: 16,
329
+ duration: 7,
330
+ compositionSrc: "stats.html",
331
+ }),
332
+ el({ id: "outro", start: 23, duration: 3, track: 1 }),
333
+ ];
334
+ const manifest = [
335
+ clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
336
+ clip({ id: "stat-1", start: 16.5, duration: 5 }),
337
+ clip({ id: "stat-2", start: 16.9, duration: 5 }),
338
+ ];
339
+ const parentMap = new Map([
340
+ ["stat-1", "s3"],
341
+ ["stat-2", "s3"],
342
+ ]);
343
+
344
+ const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
345
+
346
+ const hostIndex = out.findIndex((e) => e.id === "s3");
347
+ expect(hostIndex).toBeGreaterThanOrEqual(0);
348
+ // Host row untouched (same key → same keyframe lane), children nested under it.
349
+ expect(out[hostIndex]!.key).toBe("index.html#s3");
350
+ expect(out[hostIndex]!.track).toBe(0);
351
+ expect(out[hostIndex + 1]!.domId).toBe("stat-1");
352
+ expect(out[hostIndex + 2]!.domId).toBe("stat-2");
353
+ // Exactly one more row than the old substitution behaviour (host + 2 children + outro).
354
+ expect(out).toHaveLength(4);
355
+ });
356
+
357
+ it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
358
+ // Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
359
+ // seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
360
+ // so the whole lane went with the host row whenever the paused drill-in
361
+ // substituted it for its children.
362
+ const elements = [
363
+ el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
364
+ ];
365
+ const manifest = [
366
+ clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
367
+ clip({ id: "headline", start: 0, duration: 12 }),
368
+ ];
369
+ const parentMap = new Map([["headline", "scene"]]);
370
+
371
+ for (const currentTime of [0, 7.68, 0.2]) {
372
+ const rawId = resolveTimelineExpansionRawId({
373
+ selectedElementId: null,
374
+ isPlaying: false,
375
+ currentTime,
376
+ manifest,
377
+ parentMap,
378
+ });
379
+ const rows = rawId
380
+ ? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
381
+ : elements;
382
+ expect(rows.map((row) => row.key)).toContain("index.html#scene");
383
+ }
384
+ });
385
+
386
+ // Regression: DOM-only children were synthesized against the TOP-LEVEL element
387
+ // instead of the sub-comp host they actually live in, so every child row read
388
+ // the whole top-level window rather than its host's.
389
+ it("spans DOM-only children over their nested host's window, not the top-level one", () => {
390
+ const elements = [
391
+ el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
392
+ ];
393
+ const manifest = [
394
+ clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
395
+ clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
396
+ ];
397
+ const parentMap = new Map([
398
+ ["sub-host", "scene-host"],
399
+ ["pill-1", "sub-host"],
400
+ ]);
401
+ const domClipChildren = [
402
+ {
403
+ id: "pill-1",
404
+ parentId: "sub-host",
405
+ hostId: "sub-host",
406
+ label: "pill-1",
407
+ stackingContextId: "css:0.0",
408
+ },
409
+ ];
410
+
411
+ const out = buildExpandedElements(
412
+ elements,
413
+ manifest,
414
+ parentMap,
415
+ "scene-host",
416
+ "sub-host",
417
+ domClipChildren,
418
+ );
419
+ const pill = out.find((e) => e.domId === "pill-1")!;
420
+ expect(pill.start).toBe(5);
421
+ expect(pill.duration).toBe(6);
422
+ expect(pill.sourceFile).toBe("sub.html");
245
423
  });
246
424
  });
247
425
 
@@ -134,6 +134,27 @@ interface DisplayBounds {
134
134
  track: number;
135
135
  }
136
136
 
137
+ /**
138
+ * State that lives on the live host element, not in the clip manifest:
139
+ * `data-hidden`, `data-timeline-locked`, `data-timeline-role`. A child row is
140
+ * built from a manifest clip with no hostEl to read, so
141
+ * createTimelineElementFromManifestClip cannot see any of it. The flat store
142
+ * element for the same child WAS built with one, so it is inherited from there.
143
+ *
144
+ * Without this the eye on an expanded child always reported the row visible, so
145
+ * clicking it wrote data-hidden again instead of removing it, and a hidden child
146
+ * could never be shown again (not even after a reload, since the attribute is in
147
+ * the source).
148
+ */
149
+ function hostElementState(flat: TimelineElement | undefined): Partial<TimelineElement> {
150
+ if (!flat) return {};
151
+ return {
152
+ hidden: flat.hidden,
153
+ timelineLocked: flat.timelineLocked,
154
+ timelineRole: flat.timelineRole,
155
+ };
156
+ }
157
+
137
158
  // `display` bounds come from the top-level scene clip (where the expanded row is
138
159
  // drawn). `editBasis` comes from the child's immediate sub-comp host: its absolute
139
160
  // start anchors local-time edits and its compositionSrc is the file edits write to.
@@ -143,6 +164,7 @@ function buildChildElements(
143
164
  display: DisplayBounds,
144
165
  editBasis: { start: number; sourceFile: string | undefined },
145
166
  expandedHostKey: string,
167
+ elements: readonly TimelineElement[],
146
168
  ): TimelineElement[] {
147
169
  const result: TimelineElement[] = [];
148
170
  for (const child of siblings) {
@@ -170,6 +192,7 @@ function buildChildElements(
170
192
  });
171
193
  result.push({
172
194
  ...base,
195
+ ...hostElementState(elements.find((element) => element.key === key)),
173
196
  key,
174
197
  start: clamped.start,
175
198
  duration: clamped.duration,
@@ -209,7 +232,13 @@ function buildChildElements(
209
232
  function domSiblingClips(
210
233
  domClipChildren: DomClipChild[],
211
234
  siblingParentId: string,
212
- host: TimelineElement,
235
+ host: {
236
+ id: string | null;
237
+ start: number;
238
+ duration: number;
239
+ track: number;
240
+ compositionSrc?: string | null;
241
+ },
213
242
  ): ClipManifestClip[] {
214
243
  return domClipChildren
215
244
  .filter((c) => c.parentId === siblingParentId)
@@ -243,20 +272,23 @@ export function buildExpandedElements(
243
272
  const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
244
273
  if (!topLevelElement) return filterToTopLevel(elements, parentMap);
245
274
 
275
+ // The sub-comp host the children actually live in: top-level host for 1-level
276
+ // nesting, a nested host for deeper nesting. Its start/file anchor edits.
277
+ const parentHost = manifest.find((c) => c.id === siblingParentId);
278
+
246
279
  // Prefer real manifest children; fall back to DOM-only sub-comp children
247
280
  // (groups/pills) that have no data-start and thus never enter the manifest.
281
+ // Those are synthesized against the host they actually live in, not the
282
+ // top-level element, or every child row reads the whole top-level window.
248
283
  const siblings = (() => {
249
284
  const fromManifest = manifest.filter(
250
285
  (c) => c.id != null && parentMap.get(c.id) === siblingParentId,
251
286
  );
252
287
  if (fromManifest.length > 0) return fromManifest;
253
- return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
288
+ return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement);
254
289
  })();
255
290
  if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
256
291
 
257
- // The sub-comp host the children actually live in: top-level host for 1-level
258
- // nesting, a nested host for deeper nesting. Its start/file anchor edits.
259
- const parentHost = manifest.find((c) => c.id === siblingParentId);
260
292
  const editBasis = {
261
293
  start: parentHost?.start ?? topLevelElement.start,
262
294
  sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
@@ -272,12 +304,43 @@ export function buildExpandedElements(
272
304
  },
273
305
  editBasis,
274
306
  parentKey,
307
+ elements,
275
308
  );
276
309
  if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
277
310
 
311
+ // Every host between the drilled one and the top level owns a row, so the
312
+ // drill has to spare all of them, not just the top. A middle host is still a
313
+ // host: dropping its row drops its keyframe lane with it.
314
+ const drillPath = new Set<string>();
315
+ for (let cursor: string | undefined = siblingParentId; cursor; ) {
316
+ if (drillPath.has(cursor)) break;
317
+ drillPath.add(cursor);
318
+ if (cursor === topLevelId) break;
319
+ cursor = parentMap.get(cursor);
320
+ }
321
+ // Children hang under the DEEPEST host on that path, so anchor them there
322
+ // when it has a row of its own and fall back to the top-level row when it
323
+ // does not (a host that lives only in the manifest never had one).
324
+ const anchorsChildren = (el: TimelineElement): boolean =>
325
+ drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId)
326
+ ? (el.domId ?? el.id) === siblingParentId
327
+ : (el.key ?? el.id) === parentKey;
328
+
329
+ // ADDITIVE drill-in: the host row stays and its children are appended under
330
+ // it. Expansion is also triggered by the playhead alone (paused auto-expand),
331
+ // so substituting the host row made it vanish on an ordinary seek, and with
332
+ // it the host's keyframe lane, since diamonds render per row from
333
+ // `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
334
+ // strictly between the host's lane and the next integer, so the children have
335
+ // their own rows without the host having to give up its own.
278
336
  return elements
279
- .filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
280
- .flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
337
+ .filter(
338
+ (el) =>
339
+ (el.key ?? el.id) === parentKey ||
340
+ drillPath.has(el.domId ?? el.id) ||
341
+ !parentMap.has(el.domId ?? el.id),
342
+ )
343
+ .flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el]));
281
344
  }
282
345
 
283
346
  export function useExpandedTimelineElements(): TimelineElement[] {
@@ -1,5 +1,6 @@
1
1
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
2
2
  import type { StoreApi } from "zustand";
3
+ import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
3
4
 
4
5
  /** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
5
6
  export interface KeyframeCacheEntry {
@@ -14,9 +15,8 @@ export interface KeyframeCacheEntry {
14
15
  animationId?: string;
15
16
  properties: Record<string, number | string>;
16
17
  ease?: string;
17
- /** Set when 2+ source animations collide at this percentage (a single inline
18
- * ease button can't target one): the collapsed row hides the button here. */
19
- easeAmbiguous?: boolean;
18
+ /** Source animation/keyframe targets that collide at this clip percentage. */
19
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
20
20
  }>;
21
21
  ease?: string;
22
22
  easeEach?: string;
@@ -37,9 +37,19 @@ export interface KeyframeSlice {
37
37
 
38
38
  /** elementId scopes the request to one element so a shared (class-selector)
39
39
  * animation id can't open the ease editor on the wrong element. */
40
- focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null;
40
+ focusedEaseSegment: {
41
+ animationId: string;
42
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
43
+ tweenPercentage: number;
44
+ elementId: string;
45
+ } | null;
41
46
  setFocusedEaseSegment: (
42
- target: { animationId: string; tweenPercentage: number; elementId: string } | null,
47
+ target: {
48
+ animationId: string;
49
+ collidingAnimationTargets?: AnimationKeyframeTarget[];
50
+ tweenPercentage: number;
51
+ elementId: string;
52
+ } | null,
43
53
  ) => void;
44
54
 
45
55
  /** Keyframe data per element id, populated from parsed GSAP animations. */
@@ -56,10 +56,19 @@ describe("studio client shouldTrack", () => {
56
56
  expect(shouldTrack()).toBe(false);
57
57
  });
58
58
 
59
- it("returns false when VITE_HYPERFRAMES_NO_TELEMETRY='true'", async () => {
60
- setNoTelemetry("true");
59
+ it.each(["true", "TRUE", " yes ", "on"])(
60
+ "returns false when VITE_HYPERFRAMES_NO_TELEMETRY=%j",
61
+ async (value) => {
62
+ setNoTelemetry(value);
63
+ const shouldTrack = await loadShouldTrack();
64
+ expect(shouldTrack()).toBe(false);
65
+ },
66
+ );
67
+
68
+ it("does not opt out for an explicit false value", async () => {
69
+ setNoTelemetry("false");
61
70
  const shouldTrack = await loadShouldTrack();
62
- expect(shouldTrack()).toBe(false);
71
+ expect(shouldTrack()).toBe(true);
63
72
  });
64
73
 
65
74
  it("returns false in vite dev mode", async () => {
@@ -34,12 +34,13 @@ function isApiKeyConfigured(): boolean {
34
34
 
35
35
  // VITE_HYPERFRAMES_NO_TELEMETRY mirrors the CLI's HYPERFRAMES_NO_TELEMETRY=1
36
36
  // opt-out so HeyGen's own dev/CI builds can suppress telemetry from the studio
37
- // bundle the same way. Vite injects it at build time. Accepts "1" or "true".
37
+ // bundle the same way. Vite injects it at build time. Match the CLI's
38
+ // affirmative privacy-control spellings.
38
39
  // `import.meta.env` may be undefined in non-Vite bundlers (Next.js Turbopack).
39
40
  function isBuildTimeOptOut(): boolean {
40
41
  try {
41
42
  const v = import.meta.env.VITE_HYPERFRAMES_NO_TELEMETRY as string | undefined;
42
- return v === "1" || v === "true";
43
+ return v !== undefined && ["1", "true", "yes", "on"].includes(v.trim().toLowerCase());
43
44
  } catch {
44
45
  return false;
45
46
  }