@hyperframes/studio 0.7.71 → 0.7.73

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 (79) hide show
  1. package/dist/assets/{hyperframes-player-C1X90psg.js → hyperframes-player-DGlzhf_s.js} +1 -1
  2. package/dist/assets/index-B37rWXo4.css +1 -0
  3. package/dist/assets/{index-DzxDHPR1.js → index-CbVTOJ-E.js} +1 -1
  4. package/dist/assets/{index-nY4lLBqM.js → index-CcA5WVJv.js} +1 -1
  5. package/dist/assets/index-DXcLI2wu.js +428 -0
  6. package/dist/index.d.ts +8 -0
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +7762 -4261
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +4 -4
  12. package/src/components/StudioLeftSidebar.tsx +2 -3
  13. package/src/components/StudioRightPanel.tsx +10 -4
  14. package/src/components/editor/AnimationCard.test.tsx +100 -0
  15. package/src/components/editor/AnimationCard.tsx +5 -2
  16. package/src/components/editor/EaseCurveSection.test.tsx +348 -0
  17. package/src/components/editor/EaseCurveSection.tsx +444 -237
  18. package/src/components/editor/EaseParamFields.test.tsx +188 -0
  19. package/src/components/editor/EaseParamFields.tsx +246 -0
  20. package/src/components/editor/PropertyPanel.test.tsx +9 -7
  21. package/src/components/editor/PropertyPanelFlat.tsx +75 -88
  22. package/src/components/editor/colorGradingFrameAnalysis.test.ts +101 -0
  23. package/src/components/editor/colorGradingFrameAnalysis.ts +203 -0
  24. package/src/components/editor/colorGradingScopePatch.test.ts +3 -3
  25. package/src/components/editor/easeCurveSvg.tsx +65 -0
  26. package/src/components/editor/easePresetLibrary.test.ts +114 -0
  27. package/src/components/editor/easePresetLibrary.ts +49 -0
  28. package/src/components/editor/gsapAnimationConstants.ts +15 -13
  29. package/src/components/editor/propertyPanelColorCurveGraph.tsx +549 -0
  30. package/src/components/editor/propertyPanelColorCurves.test.tsx +158 -0
  31. package/src/components/editor/propertyPanelColorCurves.tsx +185 -0
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +115 -77
  33. package/src/components/editor/propertyPanelColorScopes.tsx +258 -0
  34. package/src/components/editor/propertyPanelColorSecondary.test.tsx +181 -0
  35. package/src/components/editor/propertyPanelColorSecondary.tsx +448 -0
  36. package/src/components/editor/propertyPanelColorWheels.test.tsx +119 -0
  37. package/src/components/editor/propertyPanelColorWheels.tsx +334 -0
  38. package/src/components/editor/propertyPanelFlatColorGradingAccessory.tsx +109 -0
  39. package/src/components/editor/propertyPanelFlatColorGradingSection.test.tsx +239 -41
  40. package/src/components/editor/propertyPanelFlatColorGradingSection.tsx +260 -300
  41. package/src/components/editor/propertyPanelFlatEffectControl.tsx +96 -0
  42. package/src/components/editor/propertyPanelFlatEffectSpecs.ts +305 -0
  43. package/src/components/editor/propertyPanelFlatEffectsSection.test.tsx +331 -0
  44. package/src/components/editor/propertyPanelFlatEffectsSection.tsx +503 -0
  45. package/src/components/editor/propertyPanelFlatOverlaysSection.test.tsx +113 -0
  46. package/src/components/editor/propertyPanelFlatOverlaysSection.tsx +108 -0
  47. package/src/components/editor/propertyPanelFlatPrimitives.tsx +2 -0
  48. package/src/components/editor/propertyPanelGradingNumberField.test.tsx +79 -0
  49. package/src/components/editor/propertyPanelGradingNumberField.tsx +116 -0
  50. package/src/components/editor/propertyPanelPresetPreview.ts +36 -0
  51. package/src/components/editor/propertyPanelTypes.ts +13 -0
  52. package/src/components/editor/useColorGradingController.test.ts +331 -10
  53. package/src/components/editor/useColorGradingController.ts +57 -90
  54. package/src/components/editor/useColorGradingPreviews.ts +350 -0
  55. package/src/components/editor/useColorGradingScopes.test.tsx +143 -0
  56. package/src/components/editor/useColorGradingScopes.ts +62 -0
  57. package/src/components/editor/useInspectorGestureTransaction.ts +30 -1
  58. package/src/components/nle/NLEContext.tsx +1 -1
  59. package/src/contexts/PanelLayoutContext.tsx +3 -6
  60. package/src/hooks/useBlockCatalog.ts +31 -13
  61. package/src/hooks/useBlockHandlers.ts +22 -0
  62. package/src/hooks/useGsapScriptCommits.test.tsx +15 -0
  63. package/src/hooks/useGsapScriptCommits.ts +14 -1
  64. package/src/hooks/usePanelLayout.test.ts +68 -1
  65. package/src/hooks/usePanelLayout.ts +72 -34
  66. package/src/icons/SystemIcons.tsx +4 -0
  67. package/src/player/components/PlayerControls.tsx +68 -229
  68. package/src/player/components/ShortcutsPanel.tsx +2 -4
  69. package/src/player/components/SpeedMenu.tsx +1 -2
  70. package/src/telemetry/events.test.ts +9 -0
  71. package/src/telemetry/events.ts +5 -0
  72. package/src/utils/blockInstaller.test.ts +67 -0
  73. package/src/utils/blockInstaller.ts +162 -109
  74. package/src/utils/studioUiPreferences.test.ts +5 -0
  75. package/src/utils/studioUiPreferences.ts +8 -0
  76. package/dist/assets/index-CSCh2Vrp.js +0 -428
  77. package/dist/assets/index-Ceyz8Qt2.css +0 -1
  78. package/src/player/components/PlayerControls.test.ts +0 -20
  79. package/src/player/components/useSeekBarDrag.ts +0 -169
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.71",
3
+ "version": "0.7.73",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,11 +46,11 @@
46
46
  "gsap": "^3.13.0",
47
47
  "marked": "^14.1.4",
48
48
  "mediabunny": "^1.45.3",
49
- "@hyperframes/core": "0.7.71",
50
- "@hyperframes/parsers": "0.7.71",
51
- "@hyperframes/player": "0.7.71",
52
- "@hyperframes/sdk": "0.7.71",
53
- "@hyperframes/studio-server": "0.7.71"
49
+ "@hyperframes/core": "0.7.73",
50
+ "@hyperframes/player": "0.7.73",
51
+ "@hyperframes/sdk": "0.7.73",
52
+ "@hyperframes/studio-server": "0.7.73",
53
+ "@hyperframes/parsers": "0.7.73"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/react": "19",
@@ -65,7 +65,7 @@
65
65
  "vite": "^6.4.2",
66
66
  "vitest": "^3.2.4",
67
67
  "zustand": "^5.0.0",
68
- "@hyperframes/producer": "0.7.71"
68
+ "@hyperframes/producer": "0.7.73"
69
69
  },
70
70
  "peerDependencies": {
71
71
  "react": "19",
package/src/App.tsx CHANGED
@@ -12,10 +12,7 @@ import { useFileManager } from "./hooks/useFileManager";
12
12
  import { usePreviewPersistence } from "./hooks/usePreviewPersistence";
13
13
  import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion";
14
14
  import { useTimelineEditing } from "./hooks/useTimelineEditing";
15
- import {
16
- persistTimelineMoveEditsAtomically,
17
- type TimelineMoveOperation,
18
- } from "./hooks/timelineMoveAdapter";
15
+ import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter";
19
16
  import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes";
20
17
  import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking";
21
18
  import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
@@ -65,6 +62,7 @@ import {
65
62
  } from "./utils/studioUrlState";
66
63
  import { trackStudioSessionStart } from "./telemetry/events";
67
64
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
65
+ type TimelineMoveOperation = Parameters<typeof persistTimelineMoveEditsAtomically>[2];
68
66
  // fallow-ignore-next-line complexity
69
67
  export function StudioApp() {
70
68
  const { projectId, resolving, waitingForServer } = useServerConnection();
@@ -204,6 +202,7 @@ export function StudioApp() {
204
202
  setActiveBlockParams,
205
203
  handleAddBlock,
206
204
  handleTimelineBlockDrop,
205
+ handleAddMediaOverlay,
207
206
  handlePreviewBlockDrop,
208
207
  } = useBlockHandlers({
209
208
  projectId,
@@ -536,6 +535,7 @@ export function StudioApp() {
536
535
  domEditSaveTimestampRef={domEditSaveTimestampRef}
537
536
  recordEdit={editHistory.recordEdit}
538
537
  onToggleElementHidden={timelineEditing.handleToggleElementHidden}
538
+ onAddMediaOverlay={handleAddMediaOverlay}
539
539
  />
540
540
  )
541
541
  }
@@ -38,7 +38,7 @@ export function StudioLeftSidebar({
38
38
  const {
39
39
  leftCollapsed,
40
40
  leftWidth,
41
- setLeftWidth,
41
+ adjustPanelWidth,
42
42
  toggleLeftSidebar,
43
43
  handlePanelResizeStart,
44
44
  handlePanelResizeMove,
@@ -173,8 +173,7 @@ export function StudioLeftSidebar({
173
173
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
174
174
  e.preventDefault();
175
175
  const delta = e.key === "ArrowLeft" ? -16 : 16;
176
- const maxLeft = Math.floor(window.innerWidth * 0.5);
177
- setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta)));
176
+ adjustPanelWidth("left", delta);
178
177
  }}
179
178
  >
180
179
  {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
@@ -31,7 +31,10 @@ import {
31
31
  EMPTY_COLOR_GRADING_SCOPE_RESULT,
32
32
  type ColorGradingScope,
33
33
  } from "./studioColorGradingScope";
34
- import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
34
+ import type {
35
+ AddMediaOverlayHandler,
36
+ BackgroundRemovalProgress,
37
+ } from "./editor/propertyPanelTypes";
35
38
  import { timelineKeysForSelections, type ToggleHiddenHandler } from "../utils/studioHelpers";
36
39
  import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
37
40
 
@@ -71,6 +74,7 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
71
74
  files: Record<string, { before: string; after: string }>;
72
75
  }) => Promise<void>;
73
76
  onToggleElementHidden?: ToggleHiddenHandler;
77
+ onAddMediaOverlay?: AddMediaOverlayHandler;
74
78
  }
75
79
 
76
80
  // fallow-ignore-next-line complexity
@@ -88,10 +92,11 @@ export function StudioRightPanel({
88
92
  domEditSaveTimestampRef,
89
93
  recordEdit,
90
94
  onToggleElementHidden,
95
+ onAddMediaOverlay,
91
96
  }: StudioRightPanelProps) {
92
97
  const {
93
98
  rightWidth,
94
- setRightWidth,
99
+ adjustPanelWidth,
95
100
  rightPanelTab,
96
101
  setRightPanelTab,
97
102
  rightInspectorPanes,
@@ -372,6 +377,7 @@ export function StudioRightPanel({
372
377
  onRemoveTextField={handleDomRemoveTextField}
373
378
  onAskAgent={handleAskAgent}
374
379
  onImportAssets={handleImportFiles}
380
+ onAddMediaOverlay={onAddMediaOverlay}
375
381
  fontAssets={fontAssets}
376
382
  onImportFonts={handleImportFonts}
377
383
  previewIframeRef={previewIframeRef}
@@ -458,7 +464,7 @@ export function StudioRightPanel({
458
464
  e.preventDefault();
459
465
  // Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
460
466
  const delta = e.key === "ArrowLeft" ? 16 : -16;
461
- setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
467
+ adjustPanelWidth("right", delta);
462
468
  }}
463
469
  >
464
470
  {/* Expanded hit zone: 8px wide, centered on the 3px seam */}
@@ -467,7 +473,7 @@ export function StudioRightPanel({
467
473
  <div className="absolute top-1/2 left-0 h-[52px] w-[3px] -translate-y-1/2 bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
468
474
  </div>
469
475
  <div
470
- className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900"
476
+ className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden rounded-lg border border-neutral-800 bg-neutral-950"
471
477
  style={{ width: rightWidth }}
472
478
  >
473
479
  {captionEditMode ? (
@@ -5,11 +5,16 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { AnimationCard } from "./AnimationCard";
7
7
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
8
+ import { EASE_PRESETS } from "./easePresetLibrary";
9
+
10
+ const trackStudioSegmentEaseEdit = vi.hoisted(() => vi.fn());
11
+ vi.mock("../../telemetry/events", () => ({ trackStudioSegmentEaseEdit }));
8
12
 
9
13
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
14
 
11
15
  afterEach(() => {
12
16
  document.body.innerHTML = "";
17
+ trackStudioSegmentEaseEdit.mockClear();
13
18
  });
14
19
 
15
20
  function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
@@ -26,6 +31,101 @@ function baseAnimation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
26
31
 
27
32
  const noop = () => {};
28
33
 
34
+ function selectPreset(host: HTMLElement, presetId: string): string {
35
+ const presetConfig = EASE_PRESETS.find((candidate) => candidate.id === presetId);
36
+ if (!presetConfig) throw new Error(`Missing ease preset: ${presetId}`);
37
+
38
+ const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
39
+ expect(dropdown).not.toBeNull();
40
+ act(() => dropdown?.click());
41
+
42
+ const preset = host.querySelector<HTMLButtonElement>(`[data-ease-preset-id="${presetId}"]`);
43
+ expect(preset).not.toBeNull();
44
+ act(() => preset?.click());
45
+ return presetConfig.ease;
46
+ }
47
+
48
+ function renderExpandedCard({
49
+ animation,
50
+ flat,
51
+ onUpdateMeta = vi.fn(),
52
+ onUpdateKeyframeEase = vi.fn(),
53
+ }: {
54
+ animation: GsapAnimation;
55
+ flat?: boolean;
56
+ onUpdateMeta?: ReturnType<typeof vi.fn>;
57
+ onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
58
+ }) {
59
+ const host = document.createElement("div");
60
+ document.body.append(host);
61
+ const root = createRoot(host);
62
+ act(() => {
63
+ root.render(
64
+ <AnimationCard
65
+ animation={animation}
66
+ defaultExpanded
67
+ flat={flat}
68
+ onUpdateProperty={noop}
69
+ onUpdateMeta={onUpdateMeta}
70
+ onDeleteAnimation={noop}
71
+ onAddProperty={noop}
72
+ onRemoveProperty={noop}
73
+ onUpdateKeyframeEase={onUpdateKeyframeEase}
74
+ />,
75
+ );
76
+ });
77
+ return { host, root };
78
+ }
79
+
80
+ describe("AnimationCard ease editing", () => {
81
+ it("commits one preset change to the selected keyframe segment", () => {
82
+ const onUpdateKeyframeEase = vi.fn();
83
+ const animation = baseAnimation({
84
+ keyframes: {
85
+ format: "percentage",
86
+ keyframes: [
87
+ { percentage: 0, properties: { opacity: 0 } },
88
+ { percentage: 50, properties: { opacity: 0.5 } },
89
+ { percentage: 100, properties: { opacity: 1 } },
90
+ ],
91
+ },
92
+ });
93
+ const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
94
+
95
+ const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
96
+ button.textContent?.includes("0% → 50%"),
97
+ );
98
+ expect(segment).toBeDefined();
99
+ act(() => segment?.click());
100
+ const ease = selectPreset(view.host, "quad-out");
101
+
102
+ expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
103
+ expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
104
+ ease,
105
+ });
106
+ act(() => view.root.unmount());
107
+ });
108
+
109
+ it("commits one preset change through flat tween metadata", () => {
110
+ const onUpdateMeta = vi.fn();
111
+ const onUpdateKeyframeEase = vi.fn();
112
+ const animation = baseAnimation({ id: "flat-tween" });
113
+ const view = renderExpandedCard({
114
+ animation,
115
+ flat: true,
116
+ onUpdateMeta,
117
+ onUpdateKeyframeEase,
118
+ });
119
+
120
+ const ease = selectPreset(view.host, "quad-out");
121
+
122
+ expect(onUpdateMeta).toHaveBeenCalledExactlyOnceWith(animation.id, { ease });
123
+ expect(onUpdateKeyframeEase).not.toHaveBeenCalled();
124
+ expect(trackStudioSegmentEaseEdit).not.toHaveBeenCalled();
125
+ act(() => view.root.unmount());
126
+ });
127
+ });
128
+
29
129
  describe("AnimationCard flat branch", () => {
30
130
  it("renders a mint border-left and panel-token colors when flat", () => {
31
131
  const host = document.createElement("div");
@@ -1,6 +1,7 @@
1
1
  import { memo, useCallback, useMemo, useState } from "react";
2
2
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { SUPPORTED_EASES, SUPPORTED_PROPS } from "@hyperframes/core/gsap-constants";
4
+ import { trackStudioSegmentEaseEdit } from "../../telemetry/events";
4
5
  import { RESPONSIVE_GRID } from "./propertyPanelHelpers";
5
6
  import { MetricField, SelectField } from "./propertyPanelPrimitives";
6
7
  import { controlPointsForGsapEase } from "./studioMotion";
@@ -264,7 +265,10 @@ export const AnimationCard = memo(function AnimationCard({
264
265
  globalEase={animation.keyframes.easeEach ?? animation.ease ?? "none"}
265
266
  expandedPct={expandedKfPct}
266
267
  onToggle={setExpandedKfPct}
267
- onEaseCommit={(pct, ease) => onUpdateKeyframeEase(animation.id, pct, ease)}
268
+ onEaseCommit={(pct, ease) => {
269
+ onUpdateKeyframeEase(animation.id, pct, ease);
270
+ trackStudioSegmentEaseEdit({ ease });
271
+ }}
268
272
  onApplyAll={
269
273
  onSetAllKeyframeEases
270
274
  ? (ease) => onSetAllKeyframeEases(animation.id, ease)
@@ -292,7 +296,6 @@ export const AnimationCard = memo(function AnimationCard({
292
296
  />
293
297
  <EaseCurveSection
294
298
  ease={easeName}
295
- duration={animation.duration}
296
299
  onCustomEaseCommit={(customEase) => {
297
300
  const easeKey = animation.keyframes ? "easeEach" : "ease";
298
301
  onUpdateMeta(animation.id, { [easeKey]: customEase });
@@ -0,0 +1,348 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act, useState } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { EaseCurveSection, MiniCurveSvg } from "./EaseCurveSection";
7
+
8
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
+
10
+ afterEach(() => {
11
+ document.body.innerHTML = "";
12
+ });
13
+
14
+ function renderSection(ease = "none", onCustomEaseCommit = vi.fn()) {
15
+ const host = document.createElement("div");
16
+ document.body.append(host);
17
+ const root = createRoot(host);
18
+ act(() => {
19
+ root.render(<EaseCurveSection ease={ease} onCustomEaseCommit={onCustomEaseCommit} />);
20
+ });
21
+ return { host, root, onCustomEaseCommit };
22
+ }
23
+
24
+ // The preset grid now lives behind the Figma-style ease-type dropdown; open it
25
+ // before querying preset tiles.
26
+ function openPresetGrid(host: HTMLElement): void {
27
+ const dropdown = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]");
28
+ act(() => dropdown!.click());
29
+ }
30
+
31
+ function presetPath(host: HTMLElement, id: string): string | null | undefined {
32
+ return host
33
+ .querySelector<HTMLButtonElement>(`[data-ease-preset-id="${id}"]`)
34
+ ?.querySelector("path")
35
+ ?.getAttribute("d");
36
+ }
37
+
38
+ function countPathExtrema(path: string): number {
39
+ const values = Array.from(path.matchAll(/[ML][^,]+,([^ ]+)/g), (match) => Number(match[1]));
40
+ const directions = values
41
+ .slice(1)
42
+ .map((value, index) => Math.sign(value - values[index]!))
43
+ .filter((direction) => direction !== 0);
44
+ return directions.filter((direction, index) => index > 0 && direction !== directions[index - 1])
45
+ .length;
46
+ }
47
+
48
+ function renderStatefulSection(initialEase = "none", onCustomEaseCommit = vi.fn()) {
49
+ const host = document.createElement("div");
50
+ document.body.append(host);
51
+ const root = createRoot(host);
52
+ const Harness = () => {
53
+ const [ease, setEase] = useState(initialEase);
54
+ return (
55
+ <EaseCurveSection
56
+ ease={ease}
57
+ onCustomEaseCommit={(nextEase) => {
58
+ onCustomEaseCommit(nextEase);
59
+ setEase(nextEase);
60
+ }}
61
+ />
62
+ );
63
+ };
64
+ act(() => root.render(<Harness />));
65
+ return { host, root, onCustomEaseCommit };
66
+ }
67
+
68
+ function clickMode(host: HTMLElement, mode: "curve" | "spring" | "wiggle"): void {
69
+ const toggle = host.querySelector<HTMLButtonElement>(`[data-ease-mode="${mode}"]`);
70
+ expect(toggle).not.toBeNull();
71
+ act(() => toggle!.click());
72
+ }
73
+
74
+ function presetIds(host: HTMLElement): string[] {
75
+ return Array.from(host.querySelectorAll<HTMLElement>("[data-ease-preset-id]"), (preset) =>
76
+ preset.getAttribute("data-ease-preset-id"),
77
+ ).filter((id): id is string => id !== null);
78
+ }
79
+
80
+ function editorLabel(host: HTMLElement): string | null {
81
+ return host.querySelector("[data-ease-type-dropdown] span")?.textContent ?? null;
82
+ }
83
+
84
+ describe("EaseCurveSection preset grid", () => {
85
+ it.each([
86
+ ["curve", "none", "linear", ["flow-7", "spring-bouncy"]],
87
+ ["spring", "spring(0.42)", "spring-bouncy", ["linear", "flow-7"]],
88
+ ["wiggle", "wiggle(3,easeInOut,0.12)", "flow-7", ["linear", "spring-bouncy"]],
89
+ ] as const)("shows only %s presets", (_mode, ease, includedPreset, excludedPresets) => {
90
+ const { host, root } = renderSection(ease);
91
+ openPresetGrid(host);
92
+ const ids = presetIds(host);
93
+
94
+ expect(ids).toContain(includedPreset);
95
+ for (const id of excludedPresets) expect(ids).not.toContain(id);
96
+
97
+ act(() => root.unmount());
98
+ });
99
+
100
+ it("renders a wiggle graph and fields without curve handles or fallback copy", () => {
101
+ const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
102
+ const graph = host.querySelector<SVGElement>('svg[viewBox="0 0 216 288"]');
103
+
104
+ expect(graph?.querySelector("path")).not.toBeNull();
105
+ expect(graph?.querySelectorAll(".cursor-grab")).toHaveLength(0);
106
+ expect(host.querySelector('[aria-label="Wiggle count"]')).not.toBeNull();
107
+ expect(host.querySelector('[aria-label="Wiggle type"]')).not.toBeNull();
108
+ expect(host.querySelector('[aria-label="Wiggle amplitude"]')).not.toBeNull();
109
+ expect(host.textContent).not.toContain("switch to");
110
+
111
+ act(() => root.unmount());
112
+ });
113
+
114
+ it.each([
115
+ ["spring(0.6)", "Bouncy"],
116
+ ["spring(0.37)", "Custom spring"],
117
+ ["wiggle(2,uniform,0.3)", "Custom wiggle"],
118
+ ["custom(M0,0 C0.1,0.2 0.8,0.9 1,1)", "Custom bezier"],
119
+ ])("labels %s as %s", (ease, expectedLabel) => {
120
+ const { host, root } = renderSection(ease);
121
+
122
+ expect(editorLabel(host)).toBe(expectedLabel);
123
+
124
+ act(() => root.unmount());
125
+ });
126
+
127
+ it("commits the selected preset through the existing custom-ease callback", () => {
128
+ const { host, root, onCustomEaseCommit } = renderSection("wiggle(3,easeInOut,0.12)");
129
+ openPresetGrid(host);
130
+ const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="flow-7"]');
131
+ expect(tile).not.toBeNull();
132
+
133
+ act(() => tile!.click());
134
+
135
+ expect(onCustomEaseCommit).toHaveBeenCalledTimes(1);
136
+ expect(onCustomEaseCommit).toHaveBeenCalledWith("wiggle(7,easeInOut,0.06)");
137
+ act(() => root.unmount());
138
+ });
139
+
140
+ it("commits Hold as a segment ease", () => {
141
+ const { host, root, onCustomEaseCommit } = renderSection();
142
+ openPresetGrid(host);
143
+ const tile = host.querySelector<HTMLButtonElement>('[data-ease-preset-id="hold"]');
144
+ expect(tile).not.toBeNull();
145
+
146
+ act(() => tile!.click());
147
+
148
+ expect(onCustomEaseCommit).toHaveBeenCalledWith("hold");
149
+ act(() => root.unmount());
150
+ });
151
+
152
+ it("draws Hold as a flat step with an end jump", () => {
153
+ const { host, root } = renderSection();
154
+ openPresetGrid(host);
155
+ const path = presetPath(host, "hold");
156
+
157
+ expect(path).toBe("M3,21 L21,21 L21,3");
158
+
159
+ act(() => root.unmount());
160
+ });
161
+
162
+ it("draws Hold as the same flat step in the big editor graph", () => {
163
+ const host = document.createElement("div");
164
+ document.body.append(host);
165
+ const root = createRoot(host);
166
+ act(() => {
167
+ root.render(<EaseCurveSection ease="hold" onCustomEaseCommit={vi.fn()} />);
168
+ });
169
+
170
+ const path = host
171
+ .querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
172
+ ?.querySelector("path")
173
+ ?.getAttribute("d");
174
+ expect(path).toBe("M16,236 L200,236 L200,52");
175
+ expect(host.querySelectorAll('[role="slider"]')).toHaveLength(0);
176
+
177
+ act(() => root.unmount());
178
+ });
179
+
180
+ it("preserves negative custom-ease control points in both curve graphs", () => {
181
+ const ease = "custom(M0,0 C0.3,-0.5 0.7,1.5 1,1)";
182
+ const host = document.createElement("div");
183
+ document.body.append(host);
184
+ const root = createRoot(host);
185
+ act(() => {
186
+ root.render(
187
+ <>
188
+ <MiniCurveSvg ease={ease} active={false} />
189
+ <EaseCurveSection ease={ease} onCustomEaseCommit={vi.fn()} />
190
+ </>,
191
+ );
192
+ });
193
+
194
+ const miniPath = host
195
+ .querySelector<SVGElement>('svg[viewBox="0 0 24 24"]')
196
+ ?.querySelector("path")
197
+ ?.getAttribute("d");
198
+ const editorPath = host
199
+ .querySelector<SVGElement>('svg[viewBox="0 0 216 288"]')
200
+ ?.querySelector("path")
201
+ ?.getAttribute("d");
202
+ expect(miniPath).toBe("M3,21 C8.399999999999999,30 15.6,-6 21,3");
203
+ expect(editorPath).toBe("M16,236 C71.19999999999999,328 144.79999999999998,-40 200,52");
204
+
205
+ act(() => root.unmount());
206
+ });
207
+
208
+ it("draws wiggle presets as sampled oscillating glyphs", () => {
209
+ const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
210
+ openPresetGrid(host);
211
+ const flow = presetPath(host, "flow-7");
212
+ const bounce = presetPath(host, "bounce-3");
213
+
214
+ expect(flow).toMatch(/^M.* L/);
215
+ expect(bounce).toMatch(/^M.* L/);
216
+ expect(flow).not.toBe(bounce);
217
+ expect(countPathExtrema(flow!)).toBeGreaterThan(8);
218
+ expect(countPathExtrema(bounce!)).toBeGreaterThan(8);
219
+
220
+ act(() => root.unmount());
221
+ });
222
+
223
+ it("draws Flow with increasing-frequency sampled glyphs", () => {
224
+ const { host, root } = renderSection("wiggle(3,easeInOut,0.12)");
225
+ openPresetGrid(host);
226
+ const flow1 = presetPath(host, "flow-1");
227
+ const flow7 = presetPath(host, "flow-7");
228
+
229
+ expect(flow1).toMatch(/^M.* L/);
230
+ expect(flow7).toMatch(/^M.* L/);
231
+ expect(flow1).not.toBe(flow7);
232
+
233
+ act(() => root.unmount());
234
+ });
235
+
236
+ it("draws explicit wiggle amplitudes in sampled glyphs", () => {
237
+ const host = document.createElement("div");
238
+ document.body.append(host);
239
+ const root = createRoot(host);
240
+ act(() =>
241
+ root.render(
242
+ <>
243
+ <MiniCurveSvg ease="wiggle(3,easeInOut,0.1)" active={false} />
244
+ <MiniCurveSvg ease="wiggle(3,easeInOut,0.2)" active={false} />
245
+ </>,
246
+ ),
247
+ );
248
+ const paths = Array.from(host.querySelectorAll("path"), (path) => path.getAttribute("d"));
249
+
250
+ expect(paths[0]).not.toBe(paths[1]);
251
+
252
+ act(() => root.unmount());
253
+ });
254
+
255
+ it("commits each mode default when switching modes", () => {
256
+ const { host, root, onCustomEaseCommit } = renderStatefulSection();
257
+
258
+ clickMode(host, "spring");
259
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.42)");
260
+
261
+ clickMode(host, "curve");
262
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.16,1 0.3,1 1,1)");
263
+
264
+ clickMode(host, "wiggle");
265
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("wiggle(3,easeInOut,0.12)");
266
+
267
+ act(() => root.unmount());
268
+ });
269
+
270
+ it("keeps spring bounce editing wired to the custom-ease callback", () => {
271
+ const { host, root, onCustomEaseCommit } = renderStatefulSection("spring(0.37)");
272
+ const bounceInput = host.querySelector<HTMLInputElement>('[aria-label="Spring bounce"]');
273
+ expect(bounceInput).not.toBeNull();
274
+
275
+ act(() => {
276
+ bounceInput!.focus();
277
+ const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
278
+ valueSetter?.call(bounceInput, "0.7");
279
+ bounceInput!.dispatchEvent(new Event("input", { bubbles: true }));
280
+ });
281
+ expect(onCustomEaseCommit).not.toHaveBeenCalled();
282
+ act(() => bounceInput!.blur());
283
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("spring(0.7)");
284
+
285
+ act(() => root.unmount());
286
+ });
287
+
288
+ it("keeps curve handles draggable and commits the edited custom curve", () => {
289
+ const { host, root, onCustomEaseCommit } = renderSection("power2.out");
290
+ const graph = host.querySelector<SVGSVGElement>('svg[viewBox="0 0 216 288"]');
291
+ const handle = graph?.querySelector<SVGCircleElement>(".cursor-grab");
292
+ expect(graph).not.toBeNull();
293
+ expect(handle).not.toBeNull();
294
+ vi.spyOn(graph!, "getBoundingClientRect").mockReturnValue(new DOMRect(0, 0, 216, 288));
295
+ handle!.setPointerCapture = vi.fn();
296
+
297
+ act(() => {
298
+ handle!.dispatchEvent(
299
+ new PointerEvent("pointerdown", { bubbles: true, pointerId: 1, clientX: 46, clientY: 52 }),
300
+ );
301
+ });
302
+ act(() => {
303
+ graph!.dispatchEvent(
304
+ new PointerEvent("pointermove", {
305
+ bubbles: true,
306
+ pointerId: 1,
307
+ clientX: 108,
308
+ clientY: 144,
309
+ }),
310
+ );
311
+ });
312
+ act(() => {
313
+ graph!.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 1 }));
314
+ });
315
+
316
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.5,0.5 0.3,1 1,1)");
317
+
318
+ act(() => root.unmount());
319
+ });
320
+
321
+ it("nudges curve handles with the keyboard", () => {
322
+ const { host, root, onCustomEaseCommit } = renderSection("power2.out");
323
+ const firstHandle = host.querySelector<SVGCircleElement>('[role="slider"]');
324
+ expect(firstHandle).not.toBeNull();
325
+
326
+ act(() => {
327
+ firstHandle!.dispatchEvent(
328
+ new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }),
329
+ );
330
+ });
331
+
332
+ expect(onCustomEaseCommit).toHaveBeenLastCalledWith("custom(M0,0 C0.17,1 0.3,1 1,1)");
333
+ act(() => root.unmount());
334
+ });
335
+
336
+ it("closes the preset menu with Escape and returns focus to its trigger", () => {
337
+ const { host, root } = renderSection();
338
+ const trigger = host.querySelector<HTMLButtonElement>("[data-ease-type-dropdown]")!;
339
+ openPresetGrid(host);
340
+
341
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
342
+ act(() => document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" })));
343
+
344
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
345
+ expect(document.activeElement).toBe(trigger);
346
+ act(() => root.unmount());
347
+ });
348
+ });