@hyperframes/studio 0.7.90 → 0.7.92

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 (86) hide show
  1. package/dist/assets/{hyperframes-player-RS9ROX_S.js → hyperframes-player-9qOa08Tv.js} +1 -1
  2. package/dist/assets/{index-Bi-CwQDX.js → index-Bw4lNdtO.js} +1 -1
  3. package/dist/assets/{index-CquRJgm-.js → index-CDTN2ZVa.js} +1 -1
  4. package/dist/assets/index-DIntkrQl.js +428 -0
  5. package/dist/index.d.ts +20 -16
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +6495 -5429
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/components/StudioRightPanel.tsx +1 -1
  11. package/src/components/editor/GsapAnimationList.tsx +84 -0
  12. package/src/components/editor/GsapAnimationSection.test.tsx +100 -11
  13. package/src/components/editor/GsapAnimationSection.tsx +11 -41
  14. package/src/components/editor/PropertyPanel.tsx +1 -1
  15. package/src/components/editor/PropertyPanelFlat.tsx +29 -26
  16. package/src/components/editor/gsapAnimationCallbacks.ts +0 -10
  17. package/src/components/editor/propertyPanelFlatDescriptors.ts +20 -0
  18. package/src/components/editor/propertyPanelFlatMotionSection.test.tsx +56 -55
  19. package/src/components/editor/propertyPanelFlatMotionSection.tsx +9 -37
  20. package/src/components/editor/propertyPanelTypes.ts +1 -1
  21. package/src/components/nle/useTimelineEditCallbacks.test.tsx +78 -4
  22. package/src/components/nle/useTimelineEditCallbacks.ts +12 -7
  23. package/src/components/renders/useRenderQueue.ts +16 -3
  24. package/src/components/renders/useRenderQueueTelemetry.test.tsx +104 -0
  25. package/src/contexts/DomEditContext.tsx +1 -1
  26. package/src/hooks/useDomEditSession.test.tsx +1 -1
  27. package/src/hooks/useDomEditSession.ts +1 -1
  28. package/src/hooks/useGsapKeyframeOps.test.tsx +83 -5
  29. package/src/hooks/useGsapKeyframeOps.ts +20 -9
  30. package/src/player/components/BeatStrip.test.tsx +374 -0
  31. package/src/player/components/BeatStrip.tsx +295 -37
  32. package/src/player/components/KeyframeDiamondContextMenu.test.tsx +16 -1
  33. package/src/player/components/KeyframeDiamondContextMenu.tsx +6 -5
  34. package/src/player/components/Timeline.test.ts +2 -3
  35. package/src/player/components/Timeline.tsx +47 -48
  36. package/src/player/components/TimelineCanvas.tsx +14 -71
  37. package/src/player/components/TimelineClip.tsx +6 -3
  38. package/src/player/components/TimelineClipDiamonds.test.tsx +442 -18
  39. package/src/player/components/TimelineClipDiamonds.tsx +66 -211
  40. package/src/player/components/TimelineGestureOverlay.test.tsx +90 -0
  41. package/src/player/components/TimelineGestureOverlay.tsx +96 -0
  42. package/src/player/components/TimelineLanes.test.tsx +0 -1
  43. package/src/player/components/TimelineLanes.tsx +3 -2
  44. package/src/player/components/TimelineOverlays.test.ts +161 -0
  45. package/src/player/components/TimelineOverlays.tsx +104 -11
  46. package/src/player/components/timelineCallbacks.ts +1 -1
  47. package/src/player/components/timelineClipDragGestureLifecycle.test.ts +18 -3
  48. package/src/player/components/timelineClipDragGestureLifecycle.ts +199 -51
  49. package/src/player/components/timelineClipDragPreview.test.ts +33 -0
  50. package/src/player/components/timelineClipDragPreview.ts +18 -12
  51. package/src/player/components/timelineClipDragTypes.ts +12 -0
  52. package/src/player/components/timelineDiamondTypes.ts +5 -15
  53. package/src/player/components/timelineDragDrop.test.tsx +206 -0
  54. package/src/player/components/timelineDragDrop.ts +152 -30
  55. package/src/player/components/timelineLaneProps.ts +0 -1
  56. package/src/player/components/timelineMarquee.test.ts +73 -25
  57. package/src/player/components/timelineMarquee.ts +44 -16
  58. package/src/player/components/timelineTestViewport.ts +13 -0
  59. package/src/player/components/timelineViewModel.ts +4 -4
  60. package/src/player/components/useTimelineClipDrag.resize.test.tsx +219 -17
  61. package/src/player/components/useTimelineClipDrag.ts +112 -70
  62. package/src/player/components/useTimelineClipRenderWindow.ts +4 -4
  63. package/src/player/components/useTimelineKeyframeHandlers.test.tsx +43 -7
  64. package/src/player/components/useTimelineKeyframeHandlers.ts +465 -2
  65. package/src/player/components/useTimelineLaneMoveRefresh.ts +7 -0
  66. package/src/player/components/useTimelineRangeSelection.test.tsx +204 -0
  67. package/src/player/components/useTimelineRangeSelection.ts +202 -69
  68. package/src/player/components/useTimelineRangeSelectionScrub.test.tsx +3 -2
  69. package/src/player/components/useTimelineRowVirtualization.ts +11 -4
  70. package/src/player/components/useTrackGapMenu.test.tsx +64 -0
  71. package/src/player/components/useTrackGapMenu.ts +32 -9
  72. package/src/player/store/keyframeSlice.ts +59 -18
  73. package/src/player/store/playerStore.test.ts +76 -0
  74. package/src/player/store/playerStore.ts +19 -11
  75. package/src/telemetry/canary.test.ts +325 -0
  76. package/src/telemetry/canary.ts +268 -0
  77. package/src/telemetry/client.test.ts +8 -3
  78. package/src/telemetry/client.ts +13 -43
  79. package/src/telemetry/config.ts +16 -2
  80. package/src/telemetry/distinctId.test.ts +35 -0
  81. package/src/telemetry/distinctId.ts +11 -2
  82. package/src/telemetry/policy.test.ts +81 -0
  83. package/src/telemetry/policy.ts +104 -0
  84. package/src/utils/studioTelemetry.test.ts +76 -0
  85. package/src/utils/studioTelemetry.ts +14 -6
  86. package/dist/assets/index-Cql15Yur.js +0 -428
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.90",
3
+ "version": "0.7.92",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.7.90",
51
- "@hyperframes/parsers": "0.7.90",
52
- "@hyperframes/player": "0.7.90",
53
- "@hyperframes/studio-server": "0.7.90",
54
- "@hyperframes/sdk": "0.7.90"
50
+ "@hyperframes/core": "0.7.92",
51
+ "@hyperframes/player": "0.7.92",
52
+ "@hyperframes/sdk": "0.7.92",
53
+ "@hyperframes/studio-server": "0.7.92",
54
+ "@hyperframes/parsers": "0.7.92"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -66,7 +66,7 @@
66
66
  "vite": "^6.4.2",
67
67
  "vitest": "^3.2.4",
68
68
  "zustand": "^5.0.0",
69
- "@hyperframes/producer": "0.7.90"
69
+ "@hyperframes/producer": "0.7.92"
70
70
  },
71
71
  "peerDependencies": {
72
72
  "react": "19",
@@ -401,8 +401,8 @@ export function StudioRightPanel({
401
401
  onUpdateArcSegment={handleUpdateArcSegment}
402
402
  onUnroll={handleUnroll}
403
403
  onUpdateKeyframeEase={handleUpdateKeyframeEase}
404
- onSetAllKeyframeEases={handleSetAllKeyframeEases}
405
404
  onUpdateSegmentEase={handleUpdateSegmentEase}
405
+ onSetAllKeyframeEases={handleSetAllKeyframeEases}
406
406
  recordingState={recordingState}
407
407
  recordingDuration={recordingDuration}
408
408
  onToggleRecording={onToggleRecording}
@@ -0,0 +1,84 @@
1
+ import { useCallback, useState } from "react";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
+ import { useShallow } from "zustand/react/shallow";
4
+ import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
5
+ import { usePlayerStore } from "../../player";
6
+ import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
7
+ import { AnimationCard } from "./AnimationCard";
8
+ import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
9
+ import {
10
+ type GsapAnimationEditCallbacks,
11
+ withTrackedGsapAnimationCallbacks,
12
+ } from "./gsapAnimationCallbacks";
13
+
14
+ interface GsapAnimationListProps extends GsapAnimationEditCallbacks {
15
+ elementId: string;
16
+ animations: GsapAnimation[];
17
+ onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
18
+ variant: "classic" | "flat";
19
+ }
20
+
21
+ /** Shared animation cards, telemetry, and timeline-ease focus ownership for both inspectors. */
22
+ export function GsapAnimationList({
23
+ elementId,
24
+ animations,
25
+ onAddAnimation,
26
+ variant,
27
+ ...callbacks
28
+ }: GsapAnimationListProps) {
29
+ const track = useTrackDesignInput();
30
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
31
+ const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
32
+ const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch, selectedElementId } =
33
+ usePlayerStore(
34
+ useShallow((state) => ({
35
+ focusedEaseSegment: state.focusedEaseSegment,
36
+ timelineProjectId: state.timelineProjectId,
37
+ timelineSessionEpoch: state.timelineSessionEpoch,
38
+ selectedElementId: state.selectedElementId,
39
+ })),
40
+ );
41
+ const focusedHere =
42
+ focusedEaseSegment &&
43
+ focusedEaseSegment.elementId === elementId &&
44
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
45
+ timelineProjectId,
46
+ timelineSessionEpoch,
47
+ selectedElementId,
48
+ }) &&
49
+ animations.some((animation) => animation.id === focusedEaseSegment.animationId)
50
+ ? focusedEaseSegment
51
+ : null;
52
+ // Stable while the request is unchanged: AnimationCard includes this callback
53
+ // in its focus-effect deps, and a fresh closure would replay that effect on
54
+ // unrelated inspector renders. Reading the action lazily also avoids a store
55
+ // subscription for a function whose identity never changes.
56
+ const consumeFocusedEaseSegment = useCallback(() => {
57
+ if (focusedHere) {
58
+ usePlayerStore.getState().clearFocusedEaseSegment(focusedHere.nonce);
59
+ }
60
+ }, [focusedHere]);
61
+
62
+ return (
63
+ <div className="space-y-2">
64
+ {animations.map((animation, index) => (
65
+ <AnimationCard
66
+ {...trackedCallbacks}
67
+ key={animation.id}
68
+ animation={animation}
69
+ defaultExpanded={index === 0}
70
+ flat={variant === "flat"}
71
+ focusedSegment={focusedHere?.animationId === animation.id ? focusedHere : null}
72
+ onFocusSegmentConsumed={consumeFocusedEaseSegment}
73
+ />
74
+ ))}
75
+ <GsapAddAnimationControl
76
+ open={addMenuOpen}
77
+ setOpen={setAddMenuOpen}
78
+ onAddAnimation={onAddAnimation}
79
+ track={track}
80
+ variant={variant}
81
+ />
82
+ </div>
83
+ );
84
+ }
@@ -5,11 +5,15 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
7
7
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
8
- import { usePlayerStore } from "../../player";
8
+ import { usePlayerStore } from "../../player/store/playerStore";
9
9
  import { GsapAnimationSection } from "./GsapAnimationSection";
10
10
 
11
11
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
12
 
13
+ const animationCardMock = vi.hoisted(() => ({
14
+ consumers: [] as Array<{ tweenPercentage: number | null; consume: () => void }>,
15
+ }));
16
+
13
17
  vi.mock("./AnimationCard", () => ({
14
18
  AnimationCard: ({
15
19
  animation,
@@ -19,22 +23,27 @@ vi.mock("./AnimationCard", () => ({
19
23
  animation: GsapAnimation;
20
24
  focusedSegment: { tweenPercentage: number } | null;
21
25
  onFocusSegmentConsumed: () => void;
22
- }) => (
23
- <button
24
- type="button"
25
- data-testid={`animation-${animation.id}`}
26
- data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
27
- // Mirrors the real card: the consume callback only fires from the effect
28
- // that runs when this card actually received a focusedSegment.
29
- onClick={() => focusedSegment && onFocusSegmentConsumed()}
30
- />
31
- ),
26
+ }) => {
27
+ animationCardMock.consumers.push({
28
+ tweenPercentage: focusedSegment?.tweenPercentage ?? null,
29
+ consume: onFocusSegmentConsumed,
30
+ });
31
+ return (
32
+ <button
33
+ type="button"
34
+ data-testid={`animation-${animation.id}`}
35
+ data-focused={focusedSegment ? String(focusedSegment.tweenPercentage) : ""}
36
+ onClick={onFocusSegmentConsumed}
37
+ />
38
+ );
39
+ },
32
40
  }));
33
41
 
34
42
  vi.mock("./GsapAddAnimationControl", () => ({ GsapAddAnimationControl: () => null }));
35
43
 
36
44
  afterEach(() => {
37
45
  document.body.innerHTML = "";
46
+ animationCardMock.consumers = [];
38
47
  usePlayerStore.getState().reset();
39
48
  });
40
49
 
@@ -76,8 +85,22 @@ function renderSection(elementId: string) {
76
85
  return { host, root, render };
77
86
  }
78
87
 
88
+ function focusSharedAnimation(elementId: string, tweenPercentage: number) {
89
+ const store = usePlayerStore.getState();
90
+ store.beginTimelineSession("project-a");
91
+ store.setSelectedElementId(elementId);
92
+ store.setFocusedEaseSegment({
93
+ elementId,
94
+ animationId: sharedAnimation.id,
95
+ tweenPercentage,
96
+ });
97
+ return store;
98
+ }
99
+
79
100
  describe("GsapAnimationSection", () => {
80
101
  it("consumes a shared animation id only for the focused element", () => {
102
+ usePlayerStore.getState().beginTimelineSession("project-a");
103
+ usePlayerStore.getState().setSelectedElementId("index.html#second");
81
104
  usePlayerStore.getState().setFocusedEaseSegment({
82
105
  elementId: "index.html#second",
83
106
  animationId: sharedAnimation.id,
@@ -99,4 +122,70 @@ describe("GsapAnimationSection", () => {
99
122
  expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
100
123
  act(() => view.root.unmount());
101
124
  });
125
+
126
+ it("does not let an older consumer clear a newer request", () => {
127
+ const store = focusSharedAnimation("index.html#first", 25);
128
+ const view = renderSection("index.html#first");
129
+ const staleConsumer = animationCardMock.consumers.at(-1)?.consume;
130
+ if (!staleConsumer) throw new Error("expected first focus consumer");
131
+
132
+ act(() => {
133
+ store.setFocusedEaseSegment({
134
+ elementId: "index.html#first",
135
+ animationId: sharedAnimation.id,
136
+ tweenPercentage: 75,
137
+ });
138
+ });
139
+ const current = usePlayerStore.getState().focusedEaseSegment;
140
+ if (!current) throw new Error("expected replacement request");
141
+
142
+ act(() => staleConsumer());
143
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(current);
144
+
145
+ const currentConsumer = animationCardMock.consumers.at(-1)?.consume;
146
+ if (!currentConsumer) throw new Error("expected replacement focus consumer");
147
+ act(() => currentConsumer());
148
+ expect(usePlayerStore.getState().focusedEaseSegment).toBeNull();
149
+ act(() => view.root.unmount());
150
+ });
151
+
152
+ it("keeps the focus consumer stable across unrelated parent renders", () => {
153
+ focusSharedAnimation("index.html#first", 25);
154
+ const view = renderSection("index.html#first");
155
+ const firstConsumer = animationCardMock.consumers.at(-1)?.consume;
156
+ if (!firstConsumer) throw new Error("expected focus consumer");
157
+
158
+ view.render("index.html#first");
159
+
160
+ expect(animationCardMock.consumers.at(-1)?.consume).toBe(firstConsumer);
161
+ act(() => view.root.unmount());
162
+ });
163
+
164
+ it("rejects a request from an earlier project session", () => {
165
+ const store = usePlayerStore.getState();
166
+ store.beginTimelineSession("project-a");
167
+ store.setSelectedElementId("index.html#first");
168
+ store.setFocusedEaseSegment({
169
+ elementId: "index.html#first",
170
+ animationId: sharedAnimation.id,
171
+ tweenPercentage: 50,
172
+ });
173
+ const staleRequest = usePlayerStore.getState().focusedEaseSegment;
174
+ if (!staleRequest) throw new Error("expected request");
175
+
176
+ const view = renderSection("index.html#first");
177
+
178
+ act(() => {
179
+ store.beginTimelineSession("project-b");
180
+ store.setSelectedElementId("index.html#first");
181
+ usePlayerStore.setState({ focusedEaseSegment: staleRequest });
182
+ });
183
+
184
+ const card = view.host.querySelector<HTMLButtonElement>(
185
+ "[data-testid='animation-shared-animation']",
186
+ );
187
+ expect(card?.dataset.focused).toBe("");
188
+ expect(usePlayerStore.getState().focusedEaseSegment).toBe(staleRequest);
189
+ act(() => view.root.unmount());
190
+ });
102
191
  });
@@ -1,16 +1,9 @@
1
- import { memo, useState } from "react";
1
+ import { memo } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { Film } from "../../icons/SystemIcons";
4
4
  import { Section } from "./propertyPanelPrimitives";
5
- import { AnimationCard } from "./AnimationCard";
6
- import {
7
- type GsapAnimationEditCallbacks,
8
- withTrackedGsapAnimationCallbacks,
9
- clearFocusedEaseSegment,
10
- } from "./gsapAnimationCallbacks";
11
- import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext";
12
- import { usePlayerStore } from "../../player";
13
- import { GsapAddAnimationControl } from "./GsapAddAnimationControl";
5
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
6
+ import { GsapAnimationList } from "./GsapAnimationList";
14
7
 
15
8
  interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
16
9
  elementId: string;
@@ -21,18 +14,13 @@ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
21
14
  }
22
15
 
23
16
  export const GsapAnimationSection = memo(function GsapAnimationSection({
24
- animations,
25
17
  elementId,
18
+ animations,
26
19
  multipleTimelines,
27
20
  unsupportedTimelinePattern,
28
21
  onAddAnimation,
29
22
  ...callbacks
30
23
  }: GsapAnimationSectionProps) {
31
- const track = useTrackDesignInput();
32
- const [addMenuOpen, setAddMenuOpen] = useState(false);
33
- const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track);
34
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
35
-
36
24
  return (
37
25
  <Section title="Animation" icon={<Film size={15} />}>
38
26
  {multipleTimelines && (
@@ -49,31 +37,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
49
37
  </p>
50
38
  )}
51
39
  {multipleTimelines || unsupportedTimelinePattern ? null : (
52
- <div className="space-y-2">
53
- {animations.map((anim, index) => (
54
- <AnimationCard
55
- {...trackedCallbacks}
56
- key={anim.id}
57
- animation={anim}
58
- defaultExpanded={index === 0}
59
- focusedSegment={
60
- focusedEaseSegment?.elementId === elementId &&
61
- focusedEaseSegment.animationId === anim.id
62
- ? focusedEaseSegment
63
- : null
64
- }
65
- onFocusSegmentConsumed={clearFocusedEaseSegment}
66
- />
67
- ))}
68
-
69
- <GsapAddAnimationControl
70
- open={addMenuOpen}
71
- setOpen={setAddMenuOpen}
72
- onAddAnimation={onAddAnimation}
73
- track={track}
74
- variant="classic"
75
- />
76
- </div>
40
+ <GsapAnimationList
41
+ {...callbacks}
42
+ elementId={elementId}
43
+ animations={animations}
44
+ onAddAnimation={onAddAnimation}
45
+ variant="classic"
46
+ />
77
47
  )}
78
48
  </Section>
79
49
  );
@@ -570,8 +570,8 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
570
570
  onUpdateArcSegment={onUpdateArcSegment}
571
571
  onUnroll={onUnroll}
572
572
  onUpdateKeyframeEase={onUpdateKeyframeEase}
573
- onSetAllKeyframeEases={onSetAllKeyframeEases}
574
573
  onUpdateSegmentEase={onUpdateSegmentEase}
574
+ onSetAllKeyframeEases={onSetAllKeyframeEases}
575
575
  />
576
576
  )}
577
577
 
@@ -1,5 +1,6 @@
1
1
  import { scopedElementKey } from "../../hooks/gsapKeyframeCacheHelpers";
2
- import { type ReactNode, useEffect, useRef, useState } from "react";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { useShallow } from "zustand/react/shallow";
3
4
  import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
4
5
  import { slugifyDesignInput } from "../../utils/designInputTracking";
5
6
  import { isTextEditableSelection } from "./domEditing";
@@ -18,6 +19,7 @@ import { createGsapLivePreview } from "./gsapLivePreview";
18
19
  import { formatTextFieldPreview } from "./propertyPanelSections";
19
20
  import { useColorGradingController } from "./useColorGradingController";
20
21
  import { usePlayerStore } from "../../player";
22
+ import { isFocusedEaseRequestCurrent } from "../../player/store/keyframeSlice";
21
23
  import {
22
24
  FlatColorGradingAccessory,
23
25
  FlatColorGradingSection,
@@ -31,24 +33,10 @@ import {
31
33
  deriveMediaOverlayPlacement,
32
34
  FlatOverlaysSection,
33
35
  } from "./propertyPanelFlatOverlaysSection";
34
-
35
- type FlatGroupDescriptor = {
36
- id: string;
37
- title: string;
38
- summary?: string;
39
- accessory?: ReactNode;
40
- content: ReactNode;
41
- };
42
-
43
- // Required callback shape for the gated-off Motion effect list.
44
- const EMPTY_GSAP_EFFECT_HANDLERS = {
45
- onAddAnimation: () => {},
46
- onUpdateProperty: () => {},
47
- onUpdateMeta: () => {},
48
- onDeleteAnimation: () => {},
49
- onAddProperty: () => {},
50
- onRemoveProperty: () => {},
51
- };
36
+ import {
37
+ EMPTY_GSAP_EFFECT_HANDLERS,
38
+ type FlatGroupDescriptor,
39
+ } from "./propertyPanelFlatDescriptors";
52
40
 
53
41
  /** The flat inspector shell with one shared open-group state. */
54
42
  // fallow-ignore-next-line complexity
@@ -161,11 +149,18 @@ export function PropertyPanelFlat({
161
149
  // When the inline timeline ease button focuses a segment on this element,
162
150
  // force the Motion group open so its AnimationCard (which only mounts while
163
151
  // the group is expanded) can consume the focus and reveal the ease editor.
164
- const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
165
- // The element THIS panel renders, not the store's selectedElementId: that
166
- // flips synchronously while the panel still renders its predecessor, so a
167
- // stale panel would consume a request meant for its successor whenever the
168
- // two share a class-selector animation id.
152
+ const { focusedEaseSegment, timelineProjectId, timelineSessionEpoch } = usePlayerStore(
153
+ useShallow((state) => ({
154
+ focusedEaseSegment: state.focusedEaseSegment,
155
+ timelineProjectId: state.timelineProjectId,
156
+ timelineSessionEpoch: state.timelineSessionEpoch,
157
+ })),
158
+ );
159
+ // Identity of the element THIS panel actually renders (not the store's
160
+ // selectedElementId, which flips synchronously on selection while the panel
161
+ // still renders the previous element during async DOM-selection resolution):
162
+ // a stale panel would otherwise consume a focus request meant for its
163
+ // successor when both share a class-selector animation id.
169
164
  const renderedElementId = scopedElementKey(element);
170
165
  // Adjusted during render (not an effect) so the card mounts on the same
171
166
  // commit the request lands on. Keyed on request identity: a group the user
@@ -174,8 +169,16 @@ export function PropertyPanelFlat({
174
169
  if (focusedEaseSegment !== consumedFocus) {
175
170
  setConsumedFocus(focusedEaseSegment);
176
171
  const focusesThisPanel =
177
- focusedEaseSegment?.elementId === renderedElementId &&
178
- gsapAnimations.some((a) => a.id === focusedEaseSegment.animationId);
172
+ focusedEaseSegment !== null &&
173
+ // A request from a previous project/session/selection is stale: it must
174
+ // not reopen Motion on whichever panel happens to be mounted now.
175
+ isFocusedEaseRequestCurrent(focusedEaseSegment, {
176
+ timelineProjectId,
177
+ timelineSessionEpoch,
178
+ selectedElementId,
179
+ }) &&
180
+ focusedEaseSegment.elementId === renderedElementId &&
181
+ gsapAnimations.some((animation) => animation.id === focusedEaseSegment.animationId);
179
182
  if (focusesThisPanel) setOpenGroupId("motion");
180
183
  }
181
184
 
@@ -1,5 +1,4 @@
1
1
  import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser";
2
- import { usePlayerStore } from "../../player";
3
2
  import type { AnimationKeyframeTarget } from "../../hooks/gsapTweenSynth";
4
3
 
5
4
  /**
@@ -164,12 +163,3 @@ export function withTrackedGsapAnimationCallbacks(
164
163
  : undefined,
165
164
  };
166
165
  }
167
-
168
- /**
169
- * Stable consumer for the store's one-shot ease-focus request. Module-level on
170
- * purpose: an inline arrow in the section components is a dep of AnimationCard's
171
- * focus effect, so a fresh identity each render re-runs that effect every render.
172
- */
173
- export function clearFocusedEaseSegment(): void {
174
- usePlayerStore.getState().setFocusedEaseSegment(null);
175
- }
@@ -0,0 +1,20 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ export type FlatGroupDescriptor = {
4
+ id: string;
5
+ title: string;
6
+ summary?: string;
7
+ accessory?: ReactNode;
8
+ content: ReactNode;
9
+ };
10
+
11
+ // FlatMotionSection never calls these while effect cards are hidden; they only
12
+ // provide its required callback shape on the gated-off path.
13
+ export const EMPTY_GSAP_EFFECT_HANDLERS = {
14
+ onAddAnimation: () => {},
15
+ onUpdateProperty: () => {},
16
+ onUpdateMeta: () => {},
17
+ onDeleteAnimation: () => {},
18
+ onAddProperty: () => {},
19
+ onRemoveProperty: () => {},
20
+ };
@@ -273,13 +273,14 @@ describe("FlatMotionSection", () => {
273
273
  expect(buttons().some((b) => b.textContent === "+ Add effect")).toBe(true);
274
274
  act(() => root.unmount());
275
275
  });
276
- });
277
276
 
278
- it("forwards focused bulk segment easing through the flat animation card", () => {
279
- const onUpdateKeyframeEase = vi.fn();
280
- const onUpdateSegmentEase = vi.fn();
281
- usePlayerStore.setState({
282
- focusedEaseSegment: {
277
+ it("forwards focused bulk segment easing through the flat animation card", () => {
278
+ const onUpdateKeyframeEase = vi.fn();
279
+ const onUpdateSegmentEase = vi.fn();
280
+ const store = usePlayerStore.getState();
281
+ store.beginTimelineSession("project-a");
282
+ store.setSelectedElementId("index.html#hero");
283
+ store.setFocusedEaseSegment({
283
284
  elementId: "index.html#hero",
284
285
  animationId: "a1",
285
286
  tweenPercentage: 50,
@@ -287,55 +288,55 @@ it("forwards focused bulk segment easing through the flat animation card", () =>
287
288
  { animationId: "a1", tweenPercentage: 50 },
288
289
  { animationId: "a2", tweenPercentage: 75 },
289
290
  ],
290
- },
291
- });
292
- const { host, root } = renderInto(
293
- <FlatMotionSection
294
- element={baseElement()}
295
- animations={[
296
- {
297
- id: "a1",
298
- method: "to",
299
- position: 0,
300
- duration: 1,
301
- properties: { x: 100 },
302
- keyframes: {
303
- format: "percentage",
304
- keyframes: [
305
- { percentage: 0, properties: { x: 0 } },
306
- { percentage: 50, properties: { x: 100 } },
307
- ],
308
- },
309
- } as never,
310
- ]}
311
- showTiming={false}
312
- showEffects
313
- onSetAttribute={vi.fn()}
314
- onAddAnimation={vi.fn()}
315
- onUpdateProperty={vi.fn()}
316
- onUpdateMeta={vi.fn()}
317
- onDeleteAnimation={vi.fn()}
318
- onAddProperty={vi.fn()}
319
- onRemoveProperty={vi.fn()}
320
- onUpdateKeyframeEase={onUpdateKeyframeEase}
321
- onUpdateSegmentEase={onUpdateSegmentEase}
322
- />,
323
- );
291
+ });
292
+ const { host, root } = renderInto(
293
+ <FlatMotionSection
294
+ element={baseElement()}
295
+ animations={[
296
+ {
297
+ id: "a1",
298
+ method: "to",
299
+ position: 0,
300
+ duration: 1,
301
+ properties: { x: 100 },
302
+ keyframes: {
303
+ format: "percentage",
304
+ keyframes: [
305
+ { percentage: 0, properties: { x: 0 } },
306
+ { percentage: 50, properties: { x: 100 } },
307
+ ],
308
+ },
309
+ } as never,
310
+ ]}
311
+ showTiming={false}
312
+ showEffects
313
+ onSetAttribute={vi.fn()}
314
+ onAddAnimation={vi.fn()}
315
+ onUpdateProperty={vi.fn()}
316
+ onUpdateMeta={vi.fn()}
317
+ onDeleteAnimation={vi.fn()}
318
+ onAddProperty={vi.fn()}
319
+ onRemoveProperty={vi.fn()}
320
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
321
+ onUpdateSegmentEase={onUpdateSegmentEase}
322
+ />,
323
+ );
324
324
 
325
- const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
326
- expect(dropdown).not.toBeNull();
327
- act(() => dropdown?.click());
328
- const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
329
- expect(preset).not.toBeNull();
330
- act(() => preset?.click());
325
+ const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
326
+ expect(dropdown).not.toBeNull();
327
+ act(() => dropdown?.click());
328
+ const preset = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="quad-out"]');
329
+ expect(preset).not.toBeNull();
330
+ act(() => preset?.click());
331
331
 
332
- expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
333
- [
334
- { animationId: "a1", tweenPercentage: 50 },
335
- { animationId: "a2", tweenPercentage: 75 },
336
- ],
337
- "power2.out",
338
- );
339
- expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
340
- act(() => root.unmount());
332
+ expect(onUpdateSegmentEase).toHaveBeenCalledExactlyOnceWith(
333
+ [
334
+ { animationId: "a1", tweenPercentage: 50 },
335
+ { animationId: "a2", tweenPercentage: 75 },
336
+ ],
337
+ "power2.out",
338
+ );
339
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
340
+ act(() => root.unmount());
341
+ });
341
342
  });