@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
@@ -0,0 +1,143 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
7
+ import { useColorGradingScopes } from "./useColorGradingScopes";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ afterEach(() => {
12
+ document.body.innerHTML = "";
13
+ vi.useRealTimers();
14
+ vi.restoreAllMocks();
15
+ });
16
+
17
+ function Harness({
18
+ open,
19
+ refreshKey,
20
+ captureFrame,
21
+ }: {
22
+ open: boolean;
23
+ refreshKey: string;
24
+ captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
25
+ }) {
26
+ const scopes = useColorGradingScopes({ open, refreshKey, captureFrame });
27
+ return (
28
+ <>
29
+ <span data-status>{scopes.status}</span>
30
+ <span data-analysis>{scopes.analysis ? "ready" : "empty"}</span>
31
+ <button type="button" onClick={scopes.refresh}>
32
+ Refresh
33
+ </button>
34
+ </>
35
+ );
36
+ }
37
+
38
+ function installPixelDecode() {
39
+ vi.spyOn(HTMLImageElement.prototype, "decode").mockResolvedValue();
40
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
41
+ drawImage: vi.fn(),
42
+ getImageData: vi.fn().mockReturnValue({
43
+ data: new Uint8ClampedArray([255, 255, 255, 255]),
44
+ }),
45
+ } as unknown as CanvasRenderingContext2D);
46
+ }
47
+
48
+ function renderScopeHarness({ open, refreshKey = "a" }: { open: boolean; refreshKey?: string }) {
49
+ vi.useFakeTimers();
50
+ installPixelDecode();
51
+ const captureFrame = vi.fn().mockResolvedValue({
52
+ dataUrl: "data:image/png;base64,scope",
53
+ width: 1,
54
+ height: 1,
55
+ });
56
+ const host = document.body.appendChild(document.createElement("div"));
57
+ const root = createRoot(host);
58
+ act(() =>
59
+ root.render(<Harness open={open} refreshKey={refreshKey} captureFrame={captureFrame} />),
60
+ );
61
+ return { captureFrame, host, root };
62
+ }
63
+
64
+ describe("useColorGradingScopes", () => {
65
+ it("captures once on open rather than polling during playback", async () => {
66
+ const { captureFrame, host, root } = renderScopeHarness({ open: false });
67
+ await act(async () => vi.advanceTimersByTimeAsync(1000));
68
+ expect(captureFrame).not.toHaveBeenCalled();
69
+
70
+ act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
71
+ await act(async () => vi.advanceTimersByTimeAsync(180));
72
+ expect(captureFrame).toHaveBeenCalledOnce();
73
+ expect(host.querySelector("[data-status]")?.textContent).toBe("ready");
74
+
75
+ await act(async () => vi.advanceTimersByTimeAsync(5000));
76
+ expect(captureFrame).toHaveBeenCalledOnce();
77
+ act(() => root.unmount());
78
+ });
79
+
80
+ it("refreshes only after a committed grading key or explicit request", async () => {
81
+ const { captureFrame, host, root } = renderScopeHarness({ open: true });
82
+ await act(async () => vi.advanceTimersByTimeAsync(180));
83
+
84
+ act(() => root.render(<Harness open refreshKey="b" captureFrame={captureFrame} />));
85
+ await act(async () => vi.advanceTimersByTimeAsync(180));
86
+ expect(captureFrame).toHaveBeenCalledTimes(2);
87
+
88
+ act(() => {
89
+ host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
90
+ });
91
+ await act(async () => vi.advanceTimersByTimeAsync(180));
92
+ expect(captureFrame).toHaveBeenCalledTimes(3);
93
+ act(() => root.unmount());
94
+ });
95
+
96
+ it("reports unavailable when capture returns no frame", async () => {
97
+ vi.useFakeTimers();
98
+ const host = document.body.appendChild(document.createElement("div"));
99
+ const root = createRoot(host);
100
+ act(() =>
101
+ root.render(<Harness open refreshKey="a" captureFrame={vi.fn().mockResolvedValue(null)} />),
102
+ );
103
+
104
+ await act(async () => vi.advanceTimersByTimeAsync(180));
105
+ expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
106
+ act(() => root.unmount());
107
+ });
108
+
109
+ it("clears stale analysis while a refresh is pending and when it is unavailable", async () => {
110
+ vi.useFakeTimers();
111
+ installPixelDecode();
112
+ let resolveRefresh: (frame: ColorGradingCapturedFrame | null) => void = () => {};
113
+ const pendingRefresh = new Promise<ColorGradingCapturedFrame | null>((resolve) => {
114
+ resolveRefresh = resolve;
115
+ });
116
+ const captureFrame = vi
117
+ .fn()
118
+ .mockResolvedValueOnce({
119
+ dataUrl: "data:image/png;base64,scope",
120
+ width: 1,
121
+ height: 1,
122
+ })
123
+ .mockReturnValueOnce(pendingRefresh);
124
+ const host = document.body.appendChild(document.createElement("div"));
125
+ const root = createRoot(host);
126
+ act(() => root.render(<Harness open refreshKey="a" captureFrame={captureFrame} />));
127
+
128
+ await act(async () => vi.advanceTimersByTimeAsync(180));
129
+ expect(host.querySelector("[data-analysis]")?.textContent).toBe("ready");
130
+
131
+ act(() => {
132
+ host.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
133
+ });
134
+ act(() => vi.advanceTimersByTime(180));
135
+ expect(host.querySelector("[data-status]")?.textContent).toBe("loading");
136
+ expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
137
+
138
+ await act(async () => resolveRefresh(null));
139
+ expect(host.querySelector("[data-status]")?.textContent).toBe("unavailable");
140
+ expect(host.querySelector("[data-analysis]")?.textContent).toBe("empty");
141
+ act(() => root.unmount());
142
+ });
143
+ });
@@ -0,0 +1,62 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { ColorGradingCapturedFrame } from "./useColorGradingPreviews";
3
+ import {
4
+ analyzeColorGradingFrame,
5
+ readColorGradingFramePixels,
6
+ type ColorGradingScopeAnalysis,
7
+ } from "./colorGradingFrameAnalysis";
8
+
9
+ type ColorGradingScopeStatus = "idle" | "loading" | "ready" | "unavailable";
10
+ const CAPTURE_DEBOUNCE_MS = 180;
11
+
12
+ export function useColorGradingScopes({
13
+ open,
14
+ captureFrame,
15
+ refreshKey,
16
+ }: {
17
+ open: boolean;
18
+ captureFrame: () => Promise<ColorGradingCapturedFrame | null>;
19
+ refreshKey: string;
20
+ }) {
21
+ const [analysis, setAnalysis] = useState<ColorGradingScopeAnalysis | null>(null);
22
+ const [status, setStatus] = useState<ColorGradingScopeStatus>("idle");
23
+ const [refreshVersion, setRefreshVersion] = useState(0);
24
+ const captureRef = useRef(captureFrame);
25
+ captureRef.current = captureFrame;
26
+
27
+ useEffect(() => {
28
+ if (!open) {
29
+ setAnalysis(null);
30
+ setStatus("idle");
31
+ return;
32
+ }
33
+ let cancelled = false;
34
+ const timer = window.setTimeout(async () => {
35
+ setAnalysis(null);
36
+ setStatus("loading");
37
+ try {
38
+ const frame = await captureRef.current();
39
+ if (cancelled) return;
40
+ if (!frame) {
41
+ setStatus("unavailable");
42
+ return;
43
+ }
44
+ const pixels = await readColorGradingFramePixels(frame);
45
+ if (!pixels) throw new Error("Canvas 2D unavailable");
46
+ const next = analyzeColorGradingFrame(pixels, frame.width, frame.height);
47
+ if (!cancelled) {
48
+ setAnalysis(next);
49
+ setStatus("ready");
50
+ }
51
+ } catch {
52
+ if (!cancelled) setStatus("unavailable");
53
+ }
54
+ }, CAPTURE_DEBOUNCE_MS);
55
+ return () => {
56
+ cancelled = true;
57
+ window.clearTimeout(timer);
58
+ };
59
+ }, [open, refreshKey, refreshVersion]);
60
+
61
+ return { analysis, status, refresh: () => setRefreshVersion((version) => version + 1) };
62
+ }
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  /** One owner for continuous inspector edits: preview freely, persist once. */
4
4
  export function useInspectorGestureTransaction<T>({
@@ -58,3 +58,32 @@ export function useInspectorGestureTransaction<T>({
58
58
 
59
59
  return { begin, preview, settle, cancel, activeRef };
60
60
  }
61
+
62
+ export function useInspectorGestureDraft<T>({
63
+ sourceValue,
64
+ onPreview,
65
+ onCommit,
66
+ }: {
67
+ sourceValue: T;
68
+ onPreview: (value: T) => void;
69
+ onCommit: (value: T) => void;
70
+ }) {
71
+ const [draft, setDraft] = useState(sourceValue);
72
+ const transaction = useInspectorGestureTransaction({
73
+ sourceValue,
74
+ onPreview: (next) => {
75
+ setDraft(next);
76
+ onPreview(next);
77
+ },
78
+ onCommit: (next) => {
79
+ setDraft(next);
80
+ onCommit(next);
81
+ },
82
+ });
83
+
84
+ useEffect(() => {
85
+ if (!transaction.activeRef.current) setDraft(sourceValue);
86
+ }, [sourceValue, transaction.activeRef]);
87
+
88
+ return { draft, setDraft, transaction };
89
+ }
@@ -19,7 +19,7 @@ import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
19
19
 
20
20
  // Timeline gets a generous default height so the preview isn't oversized and the
21
21
  // tracks have room to breathe (CapCut-style). Users can still drag the divider.
22
- const DEFAULT_TIMELINE_H = 340;
22
+ const DEFAULT_TIMELINE_H = 360;
23
23
 
24
24
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
25
25
  return compositionLoading;
@@ -14,9 +14,8 @@ export function usePanelLayoutContext(): PanelLayoutValue {
14
14
  export function PanelLayoutProvider({
15
15
  value: {
16
16
  leftWidth,
17
- setLeftWidth,
18
17
  rightWidth,
19
- setRightWidth,
18
+ adjustPanelWidth,
20
19
  leftCollapsed,
21
20
  setLeftCollapsed,
22
21
  rightCollapsed,
@@ -39,9 +38,8 @@ export function PanelLayoutProvider({
39
38
  const stable = useMemo<PanelLayoutValue>(
40
39
  () => ({
41
40
  leftWidth,
42
- setLeftWidth,
43
41
  rightWidth,
44
- setRightWidth,
42
+ adjustPanelWidth,
45
43
  leftCollapsed,
46
44
  setLeftCollapsed,
47
45
  rightCollapsed,
@@ -58,9 +56,8 @@ export function PanelLayoutProvider({
58
56
  }),
59
57
  [
60
58
  leftWidth,
61
- setLeftWidth,
62
59
  rightWidth,
63
- setRightWidth,
60
+ adjustPanelWidth,
64
61
  leftCollapsed,
65
62
  setLeftCollapsed,
66
63
  rightCollapsed,
@@ -6,33 +6,51 @@ import {
6
6
  resolveBlockCategory,
7
7
  } from "../utils/blockCategories";
8
8
 
9
- export type CatalogItem = RegistryItem & {
9
+ type CatalogItem = RegistryItem & {
10
10
  category: BlockCategory;
11
11
  };
12
12
 
13
+ let catalogCache: CatalogItem[] | null = null;
14
+ let catalogRequest: Promise<CatalogItem[]> | null = null;
15
+
16
+ function loadCatalog(): Promise<CatalogItem[]> {
17
+ catalogRequest ??= fetch("/api/registry/blocks")
18
+ .then((response) => {
19
+ if (!response.ok) throw new Error("Failed to load catalog");
20
+ return response.json() as Promise<RegistryItem[]>;
21
+ })
22
+ .then((items) => {
23
+ catalogCache = items
24
+ .map((item) => ({ ...item, category: resolveBlockCategory(item.tags) }))
25
+ .sort((a, b) => {
26
+ const aIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === a.category);
27
+ const bIndex = BLOCK_CATEGORIES.findIndex((category) => category.id === b.category);
28
+ return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex);
29
+ });
30
+ return catalogCache;
31
+ })
32
+ .catch((error: unknown) => {
33
+ catalogRequest = null;
34
+ throw error;
35
+ });
36
+ return catalogRequest;
37
+ }
38
+
13
39
  export function useBlockCatalog() {
14
- const [blocks, setBlocks] = useState<CatalogItem[]>([]);
15
- const [loading, setLoading] = useState(true);
40
+ const [blocks, setBlocks] = useState<CatalogItem[]>(() => catalogCache ?? []);
41
+ const [loading, setLoading] = useState(catalogCache === null);
16
42
  const [error, setError] = useState<string | null>(null);
17
43
  const [search, setSearch] = useState("");
18
44
  const [category, setCategory] = useState<BlockCategory | null>(null);
19
45
 
20
46
  // fallow-ignore-next-line complexity
21
47
  useEffect(() => {
48
+ if (catalogCache) return;
22
49
  let cancelled = false;
23
50
  (async () => {
24
51
  try {
25
- const res = await fetch("/api/registry/blocks");
26
- if (!res.ok) throw new Error("Failed to load catalog");
27
- const data = (await res.json()) as RegistryItem[];
52
+ const items = await loadCatalog();
28
53
  if (cancelled) return;
29
- const items = data
30
- .map((b) => ({ ...b, category: resolveBlockCategory(b.tags) }))
31
- .sort((a, b) => {
32
- const ia = BLOCK_CATEGORIES.findIndex((c) => c.id === a.category);
33
- const ib = BLOCK_CATEGORIES.findIndex((c) => c.id === b.category);
34
- return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
35
- });
36
54
  setBlocks(items);
37
55
  } catch (err) {
38
56
  if (cancelled) return;
@@ -9,6 +9,7 @@ import { addBlockToProject } from "../utils/blockInstaller";
9
9
  import type { BlockParam } from "@hyperframes/core/registry";
10
10
  import type { EditHistoryKind } from "../utils/editHistory";
11
11
  import type { RightPanelTab } from "../utils/studioHelpers";
12
+ import type { MediaOverlayPlacement } from "../components/editor/propertyPanelTypes";
12
13
 
13
14
  interface BlockCtxDeps {
14
15
  activeCompPath: string | null;
@@ -46,6 +47,7 @@ export interface UseBlockHandlersResult {
46
47
  >;
47
48
  handleAddBlock: (blockName: string) => void;
48
49
  handleTimelineBlockDrop: (blockName: string, placement: { start: number; track: number }) => void;
50
+ handleAddMediaOverlay: (blockName: string, placement: MediaOverlayPlacement) => Promise<void>;
49
51
  handlePreviewBlockDrop: (blockName: string, position: { left: number; top: number }) => void;
50
52
  }
51
53
 
@@ -151,6 +153,25 @@ export function useBlockHandlers({
151
153
  [projectId, blockCtx, previewIframeRef, runBlockInstall],
152
154
  );
153
155
 
156
+ const handleAddMediaOverlay = useCallback(
157
+ async (blockName: string, placement: MediaOverlayPlacement) => {
158
+ if (!projectId) return;
159
+ const { compositionPath, ...timelinePlacement } = placement;
160
+ await runBlockInstall(blockName, () =>
161
+ addBlockToProject({
162
+ projectId,
163
+ blockName,
164
+ ...blockCtx,
165
+ activeCompPath: compositionPath ?? blockCtx.activeCompPath,
166
+ placement: timelinePlacement,
167
+ previewIframe: previewIframeRef.current,
168
+ currentTime: usePlayerStore.getState().currentTime,
169
+ }),
170
+ );
171
+ },
172
+ [projectId, blockCtx, previewIframeRef, runBlockInstall],
173
+ );
174
+
154
175
  const handlePreviewBlockDrop = useCallback(
155
176
  (blockName: string, position: { left: number; top: number }) => {
156
177
  if (!projectId) return;
@@ -173,6 +194,7 @@ export function useBlockHandlers({
173
194
  setActiveBlockParams,
174
195
  handleAddBlock,
175
196
  handleTimelineBlockDrop,
197
+ handleAddMediaOverlay,
176
198
  handlePreviewBlockDrop,
177
199
  };
178
200
  }
@@ -283,6 +283,21 @@ function mockFetchResult(over: Partial<MutationResult> = {}): void {
283
283
  }
284
284
 
285
285
  describe("runCommit — instantPatch wiring", () => {
286
+ it("explains a deliberate mutation that the server safely rejected as unchanged", async () => {
287
+ mockFetchResult({ changed: false });
288
+ const deps = renderCommitHook();
289
+
290
+ await act(async () => {
291
+ await deps.api.commitMutation(
292
+ selection,
293
+ { type: "move-keyframe", fromPercentage: 50, toPercentage: 100 },
294
+ { label: "Move keyframe" },
295
+ );
296
+ });
297
+
298
+ expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
299
+ });
300
+
286
301
  it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
287
302
  patchRuntimeTweenInPlace.mockReturnValue(true);
288
303
  mockFetchResult({ changed: false });
@@ -77,6 +77,17 @@ async function mutateGsapScriptBatch(
77
77
 
78
78
  type ShowToast = (message: string, tone?: "error" | "info") => void;
79
79
 
80
+ function showUnchangedMutationFeedback(
81
+ mutations: Record<string, unknown>[],
82
+ result: MutationResult,
83
+ showToast: ShowToast | undefined,
84
+ ): void {
85
+ if (result.changed !== false || mutations.length !== 1) return;
86
+ if (mutations[0]?.type === "move-keyframe") {
87
+ showToast?.("A keyframe already exists at that time", "info");
88
+ }
89
+ }
90
+
80
91
  async function runMutationRequest(
81
92
  mutations: Record<string, unknown>[],
82
93
  options: CommitMutationOptions,
@@ -94,7 +105,9 @@ async function runMutationRequest(
94
105
  );
95
106
  }
96
107
  try {
97
- return await request();
108
+ const result = await request();
109
+ showUnchangedMutationFeedback(mutations, result, showToast);
110
+ return result;
98
111
  } catch (error) {
99
112
  if (error instanceof GsapMutationHttpError) {
100
113
  showToast?.(formatGsapMutationRejectionToast(error), "error");
@@ -2,11 +2,30 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
6
7
  import { usePanelLayout } from "./usePanelLayout";
7
8
 
8
9
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
9
10
 
11
+ beforeEach(() => {
12
+ const entries = new Map<string, string>();
13
+ Object.defineProperty(window, "localStorage", {
14
+ configurable: true,
15
+ value: {
16
+ get length() {
17
+ return entries.size;
18
+ },
19
+ clear: () => entries.clear(),
20
+ getItem: (key: string) => entries.get(key) ?? null,
21
+ key: (index: number) => Array.from(entries.keys())[index] ?? null,
22
+ removeItem: (key: string) => entries.delete(key),
23
+ setItem: (key: string, value: string) => entries.set(key, value),
24
+ } satisfies Storage,
25
+ });
26
+ Object.defineProperty(window, "innerWidth", { configurable: true, value: 1496 });
27
+ });
28
+
10
29
  afterEach(() => {
11
30
  document.body.innerHTML = "";
12
31
  vi.doUnmock("../components/editor/manualEditingAvailability");
@@ -42,6 +61,54 @@ function renderPanelLayout() {
42
61
  }
43
62
 
44
63
  describe("usePanelLayout — right inspector panes", () => {
64
+ it("opens Design with the intended viewport-scaled panel widths", () => {
65
+ const harness = renderPanelLayout();
66
+
67
+ expect(harness.getState()).toMatchObject({
68
+ leftWidth: 384,
69
+ rightWidth: 424,
70
+ rightCollapsed: false,
71
+ rightPanelTab: "design",
72
+ });
73
+
74
+ harness.unmount();
75
+ });
76
+
77
+ it("persists the latest pointer width even before React rerenders", () => {
78
+ const harness = renderPanelLayout();
79
+ const state = harness.getState();
80
+ const target = { setPointerCapture: vi.fn() };
81
+
82
+ act(() => {
83
+ state.handlePanelResizeStart("left", {
84
+ preventDefault: vi.fn(),
85
+ target,
86
+ pointerId: 1,
87
+ clientX: 100,
88
+ } as unknown as React.PointerEvent);
89
+ state.handlePanelResizeMove({ clientX: 140 } as React.PointerEvent);
90
+ state.handlePanelResizeEnd();
91
+ });
92
+
93
+ expect(harness.getState().leftWidth).toBe(424);
94
+ expect(readStudioUiPreferences().leftWidth).toBe(424);
95
+ harness.unmount();
96
+ });
97
+
98
+ it("accumulates and persists rapid keyboard resize steps", () => {
99
+ const harness = renderPanelLayout();
100
+ const state = harness.getState();
101
+
102
+ act(() => {
103
+ state.adjustPanelWidth("right", 16);
104
+ state.adjustPanelWidth("right", 16);
105
+ });
106
+
107
+ expect(harness.getState().rightWidth).toBe(456);
108
+ expect(readStudioUiPreferences().rightWidth).toBe(456);
109
+ harness.unmount();
110
+ });
111
+
45
112
  it("toggleRightInspectorPane independently flips one pane, allowing both open at once", () => {
46
113
  const harness = renderPanelLayout();
47
114
  expect(harness.getState().rightInspectorPanes).toEqual({ layers: false, design: true });