@hyperframes/studio 0.7.99 → 0.7.100

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.7.99",
3
+ "version": "0.7.100",
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/parsers": "0.7.99",
51
- "@hyperframes/core": "0.7.99",
52
- "@hyperframes/sdk": "0.7.99",
53
- "@hyperframes/player": "0.7.99",
54
- "@hyperframes/studio-server": "0.7.99"
50
+ "@hyperframes/core": "0.7.100",
51
+ "@hyperframes/player": "0.7.100",
52
+ "@hyperframes/sdk": "0.7.100",
53
+ "@hyperframes/studio-server": "0.7.100",
54
+ "@hyperframes/parsers": "0.7.100"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -67,7 +67,7 @@
67
67
  "vite": "^6.4.2",
68
68
  "vitest": "^3.2.4",
69
69
  "zustand": "^5.0.0",
70
- "@hyperframes/producer": "0.7.99"
70
+ "@hyperframes/producer": "0.7.100"
71
71
  },
72
72
  "peerDependencies": {
73
73
  "react": "19",
package/src/App.tsx CHANGED
@@ -399,7 +399,7 @@ export function StudioApp() {
399
399
  } = useInspectorState(
400
400
  panelLayout.rightPanelTab,
401
401
  panelLayout.rightInspectorPanes,
402
- panelLayout.rightCollapsed,
402
+ panelLayout.effectiveRightCollapsed,
403
403
  isPlaying,
404
404
  domEditSession.domEditSelection,
405
405
  gestureState === "recording",
@@ -512,7 +512,7 @@ export function StudioApp() {
512
512
  />
513
513
  }
514
514
  right={
515
- panelLayout.rightCollapsed ? null : (
515
+ panelLayout.effectiveRightCollapsed ? null : (
516
516
  <StudioRightPanel
517
517
  designPanelActive={designPanelActive}
518
518
  activeBlockParams={activeBlockParams}
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldOpenInspector } from "./StudioHeader";
3
+
4
+ describe("shouldOpenInspector", () => {
5
+ it("opens when the panel is hidden", () => {
6
+ expect(shouldOpenInspector(true, false)).toBe(true);
7
+ });
8
+
9
+ it("opens when a non-inspector tab is showing", () => {
10
+ expect(shouldOpenInspector(false, false)).toBe(true);
11
+ });
12
+
13
+ it("closes when the inspector is genuinely on screen", () => {
14
+ expect(shouldOpenInspector(false, true)).toBe(false);
15
+ });
16
+
17
+ it("opens when the window railed the panel away", () => {
18
+ // The regression this guards: the button used to branch on the raw
19
+ // rightCollapsed intent, which is still `false` while the window has the
20
+ // panel railed. That took the close branch, wrote rightCollapsed=true, and
21
+ // since that value is synced into the shareable Studio URL, a click that
22
+ // did nothing visible rewrote the link.
23
+ const userIntentIsOpen = false;
24
+ const windowRailedItAway = true;
25
+ expect(shouldOpenInspector(windowRailedItAway, true)).toBe(true);
26
+ expect(shouldOpenInspector(userIntentIsOpen, true)).toBe(false);
27
+ });
28
+ });
@@ -196,6 +196,21 @@ export function ViewModeToggle() {
196
196
  );
197
197
  }
198
198
 
199
+ /**
200
+ * Does the header's Inspector button open the panel, or close it?
201
+ *
202
+ * Takes the EFFECTIVE collapse state, so a panel the window has railed away
203
+ * counts as closed even though the user's stored intent still says open. The
204
+ * argument name is the guard: passing raw intent here is the bug this exists
205
+ * to keep out.
206
+ */
207
+ export function shouldOpenInspector(
208
+ effectiveRightCollapsed: boolean,
209
+ inspectorPanelActive: boolean,
210
+ ): boolean {
211
+ return effectiveRightCollapsed || !inspectorPanelActive;
212
+ }
213
+
199
214
  // fallow-ignore-next-line complexity
200
215
  export function StudioHeader({
201
216
  captureFrameHref,
@@ -208,7 +223,11 @@ export function StudioHeader({
208
223
  onExport,
209
224
  }: StudioHeaderProps) {
210
225
  const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
211
- const { rightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
226
+ // effectiveRightCollapsed, not the raw intent: in the auto-railed state the
227
+ // intent is still "open" while the panel is hidden, so branching on intent
228
+ // made this button write rightCollapsed=true — and that value is synced into
229
+ // the shareable Studio URL, so a dead click would rewrite a link.
230
+ const { effectiveRightCollapsed, setRightCollapsed, setRightPanelTab } = usePanelLayoutContext();
212
231
  const isRendering = renderQueue.isRendering;
213
232
 
214
233
  return (
@@ -328,7 +347,7 @@ export function StudioHeader({
328
347
  <button
329
348
  type="button"
330
349
  onClick={() => {
331
- if (rightCollapsed || !inspectorPanelActive) {
350
+ if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
332
351
  trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
333
352
  setRightPanelTab("design");
334
353
  setRightCollapsed(false);
@@ -36,7 +36,7 @@ export function StudioLeftSidebar({
36
36
  onAddCompositionToTimeline,
37
37
  }: StudioLeftSidebarProps) {
38
38
  const {
39
- leftCollapsed,
39
+ effectiveLeftCollapsed,
40
40
  leftWidth,
41
41
  adjustPanelWidth,
42
42
  toggleLeftSidebar,
@@ -71,7 +71,7 @@ export function StudioLeftSidebar({
71
71
  [renderQueue, waitForPendingDomEditSaves],
72
72
  );
73
73
 
74
- if (leftCollapsed) {
74
+ if (effectiveLeftCollapsed) {
75
75
  return (
76
76
  <div className="mr-0.5 flex w-10 flex-shrink-0 flex-col items-center rounded-lg border border-neutral-800/50 bg-neutral-950 pt-1">
77
77
  <button
@@ -11,7 +11,7 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
11
11
  import type { TimelineElement } from "../../player";
12
12
  import type { CompositionLevel } from "./CompositionBreadcrumb";
13
13
  import { useCompositionStack } from "./useCompositionStack";
14
- import { MIN_TIMELINE_H, MIN_PREVIEW_H } from "./TimelineResizeDivider";
14
+ import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
15
15
  import { setCompositionSourceMap } from "../editor/domEditingDom";
16
16
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
17
17
  import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
@@ -273,13 +273,22 @@ export function NLEProvider({
273
273
  }, []);
274
274
  const containerRef = useRef<HTMLDivElement>(null);
275
275
  // A height persisted on a tall window can exceed this window's container and
276
- // collapse the flex-1 preview to 0px clamp once the container is measurable
277
- // (the drag/keyboard paths already clamp; the restore path must too).
276
+ // collapse the flex-1 preview to 0px. Observing the container rather than
277
+ // clamping once at mount is what makes a window RESIZED after load behave the
278
+ // same as one loaded at that size: dragging 760 -> 520 tall used to leave the
279
+ // timeline at its stored 429px and the preview at 47px.
278
280
  useEffect(() => {
279
- const containerH = containerRef.current?.getBoundingClientRect().height;
280
- if (!containerH) return;
281
- const max = containerH - MIN_PREVIEW_H;
282
- setTimelineH((prev) => (prev > max ? Math.max(MIN_TIMELINE_H, max) : prev));
281
+ const element = containerRef.current;
282
+ if (!element || typeof ResizeObserver === "undefined") return;
283
+ const reconcile = () => {
284
+ const containerH = element.getBoundingClientRect().height;
285
+ if (!containerH) return;
286
+ setTimelineH((prev) => fitTimelineHeight(containerH, prev));
287
+ };
288
+ reconcile();
289
+ const observer = new ResizeObserver(reconcile);
290
+ observer.observe(element);
291
+ return () => observer.disconnect();
283
292
  }, []);
284
293
 
285
294
  const hasLoadedOnceRef = useRef(false);
@@ -1,7 +1,5 @@
1
1
  import { useCallback, useRef } from "react";
2
-
3
- export const MIN_TIMELINE_H = 100;
4
- export const MIN_PREVIEW_H = 120;
2
+ import { MIN_PREVIEW_H, MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
5
3
 
6
4
  /**
7
5
  * Horizontal drag/keyboard-resizable divider between the preview and the
@@ -41,12 +39,7 @@ export function TimelineResizeDivider({
41
39
  if (!isDragging.current || !containerRef.current) return;
42
40
  const rect = containerRef.current.getBoundingClientRect();
43
41
  const mouseY = e.clientY - rect.top;
44
- const containerH = rect.height;
45
- const newTimelineH = Math.max(
46
- MIN_TIMELINE_H,
47
- Math.min(containerH - MIN_PREVIEW_H, containerH - mouseY),
48
- );
49
- setTimelineH(newTimelineH);
42
+ setTimelineH(fitTimelineHeight(rect.height, rect.height - mouseY));
50
43
  },
51
44
  [disabled, containerRef, setTimelineH],
52
45
  );
@@ -61,10 +54,10 @@ export function TimelineResizeDivider({
61
54
  if (disabled) return;
62
55
  if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
63
56
  e.preventDefault();
64
- const containerH = containerRef.current?.getBoundingClientRect().height ?? Infinity;
57
+ const containerH = containerRef.current?.getBoundingClientRect().height ?? 0;
65
58
  const delta = e.key === "ArrowUp" ? 16 : -16;
66
59
  setTimelineH((prev) => {
67
- const next = Math.max(MIN_TIMELINE_H, Math.min(containerH - MIN_PREVIEW_H, prev + delta));
60
+ const next = fitTimelineHeight(containerH, prev + delta);
68
61
  persistTimelineH(next);
69
62
  return next;
70
63
  });
@@ -17,9 +17,10 @@ export function PanelLayoutProvider({
17
17
  rightWidth,
18
18
  adjustPanelWidth,
19
19
  leftCollapsed,
20
- setLeftCollapsed,
21
20
  rightCollapsed,
22
21
  setRightCollapsed,
22
+ effectiveLeftCollapsed,
23
+ effectiveRightCollapsed,
23
24
  rightPanelTab,
24
25
  setRightPanelTab,
25
26
  rightInspectorPanes,
@@ -41,9 +42,10 @@ export function PanelLayoutProvider({
41
42
  rightWidth,
42
43
  adjustPanelWidth,
43
44
  leftCollapsed,
44
- setLeftCollapsed,
45
45
  rightCollapsed,
46
46
  setRightCollapsed,
47
+ effectiveLeftCollapsed,
48
+ effectiveRightCollapsed,
47
49
  rightPanelTab,
48
50
  setRightPanelTab,
49
51
  rightInspectorPanes,
@@ -59,9 +61,10 @@ export function PanelLayoutProvider({
59
61
  rightWidth,
60
62
  adjustPanelWidth,
61
63
  leftCollapsed,
62
- setLeftCollapsed,
63
64
  rightCollapsed,
64
65
  setRightCollapsed,
66
+ effectiveLeftCollapsed,
67
+ effectiveRightCollapsed,
65
68
  rightPanelTab,
66
69
  setRightPanelTab,
67
70
  rightInspectorPanes,
@@ -3,7 +3,25 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/
3
3
  export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable";
4
4
 
5
5
  export type GsapEditOutcome =
6
- | { status: "persisted" }
6
+ | {
7
+ status: "persisted";
8
+ /**
9
+ * Whether this edit already accounted for where the gesture left the
10
+ * element, so the caller must not persist the drag offset on top.
11
+ *
12
+ * The scale route needs it: a committed scale renders around the element
13
+ * centre rather than the dragged corner, so it measures the difference
14
+ * and writes the position itself. Every other route moves nothing the
15
+ * caller has not already been told about, and the caller owns the offset.
16
+ *
17
+ * It has to be reported rather than inferred. The caller used to guess
18
+ * from "does this element have a scale-group tween", which is true for an
19
+ * element whose scale is an instant hold — but that resize commits
20
+ * width/height, not scale, so the guess withheld an offset nobody wrote
21
+ * and the element snapped back to its authored position on every drag.
22
+ */
23
+ ownsDragOffset?: boolean;
24
+ }
7
25
  | { status: "blocked"; reason: GsapEditBlockReason };
8
26
 
9
27
  const COPY: Record<GsapEditBlockReason, string> = {
@@ -0,0 +1,289 @@
1
+ // @vitest-environment happy-dom
2
+ /**
3
+ * What the resize COMMITS, checked as geometry rather than as structure.
4
+ *
5
+ * The structural sweep beside this one proves a resize never addresses a
6
+ * missing animation and never leaves a tween spanning two property groups.
7
+ * Neither says the box ends up the size the user dragged it to, which is the
8
+ * thing they are actually looking at.
9
+ *
10
+ * The invariant is split by who owns the drop point, because the two halves are
11
+ * genuinely different jobs:
12
+ *
13
+ * - The committed size or scale must reproduce the RENDERED box the user
14
+ * dropped, whatever rotation is on the element. This is the resize's job in
15
+ * every route.
16
+ * - When the resize reports `ownsDragOffset`, the box must also land on the
17
+ * drop POINT, because it has taken responsibility for the position. When it
18
+ * does not, position is the drag's job and is not asserted here.
19
+ *
20
+ * Rotation is the reason this exists. The committed scale is worked out from
21
+ * the element's CSS box, and a rotated element's rendered box is not its CSS
22
+ * box — so the two are only equal if the drafted size is in CSS-box terms all
23
+ * the way through. A sweep across rotations is what tells us it is.
24
+ */
25
+ import { afterEach, expect, it, vi } from "vitest";
26
+ import { classifyTweenPropertyGroup } from "@hyperframes/core/gsap-parser";
27
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
28
+ import type { DomEditSelection } from "../components/editor/domEditingTypes";
29
+ import { usePlayerStore } from "../player/store/playerStore";
30
+ import { tryGsapResizeIntercept } from "./gsapResizeIntercept";
31
+
32
+ afterEach(() => {
33
+ vi.restoreAllMocks();
34
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
35
+ document.body.innerHTML = "";
36
+ });
37
+
38
+ const LAYOUT = { left: 120, top: 520 };
39
+
40
+ interface Pose {
41
+ box: { w: number; h: number };
42
+ pos: { x: number; y: number };
43
+ scale: { x: number; y: number };
44
+ }
45
+
46
+ /** The AABB a browser reports for `translate() rotate() scale()` about the centre. */
47
+ function renderRect(pose: Pose, rotationDeg: number) {
48
+ const rad = (rotationDeg * Math.PI) / 180;
49
+ const [cos, sin] = [Math.abs(Math.cos(rad)), Math.abs(Math.sin(rad))];
50
+ const [sw, sh] = [pose.box.w * pose.scale.x, pose.box.h * pose.scale.y];
51
+ const w = sw * cos + sh * sin;
52
+ const h = sw * sin + sh * cos;
53
+ const cx = LAYOUT.left + pose.box.w / 2 + pose.pos.x;
54
+ const cy = LAYOUT.top + pose.box.h / 2 + pose.pos.y;
55
+ return { x: cx - w / 2, y: cy - h / 2, w, h };
56
+ }
57
+
58
+ type Props = Record<string, number>;
59
+
60
+ function tween(id: string, properties: Props, duration: number): GsapAnimation {
61
+ return {
62
+ id,
63
+ targetSelector: "#el",
64
+ propertyGroup: classifyTweenPropertyGroup(properties),
65
+ method: "to",
66
+ properties,
67
+ position: 0,
68
+ resolvedStart: 0,
69
+ duration,
70
+ ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}),
71
+ } as unknown as GsapAnimation;
72
+ }
73
+
74
+ interface Case {
75
+ name: string;
76
+ /** The element's untransformed CSS box. */
77
+ box: { w: number; h: number };
78
+ /** Where it sat, and at what scale, before the gesture. */
79
+ base: { x: number; y: number };
80
+ liveScale: { x: number; y: number };
81
+ rotation: number;
82
+ /** The box the user dragged to, and where the draft put it. */
83
+ drop: { w: number; h: number; x: number; y: number };
84
+ animations: () => GsapAnimation[];
85
+ /** Whether this route takes responsibility for where the box lands. */
86
+ settles: boolean;
87
+ }
88
+
89
+ const ROTATIONS = [0, -8, 45, -47, 90, 180];
90
+
91
+ /**
92
+ * The routes a resize can take, and whether each SETTLES the drop point.
93
+ *
94
+ * Only a committed scale moves the box: it renders about the element centre
95
+ * rather than the dragged corner, so the route measures the difference and
96
+ * writes the position. Every other route commits width and height and moves
97
+ * nothing, which leaves the anchor to the drag. An element whose scale is an
98
+ * instant hold has a scale tween and still commits size, so it belongs with
99
+ * the size routes here however it looks from the animation list.
100
+ */
101
+ const ROUTES = {
102
+ "scale tween": { animations: () => [tween("#el-scale", { scale: 1 }, 2)], settles: true },
103
+ "scale longhands": {
104
+ animations: () => [tween("#el-scale", { scaleX: 1, scaleY: 1 }, 2)],
105
+ settles: true,
106
+ },
107
+ "scale instant hold": { animations: () => [tween("#el-scale", { scale: 1 }, 0)], settles: false },
108
+ "size tween": {
109
+ animations: () => [tween("#el-size", { width: 630, height: 408 }, 2)],
110
+ settles: false,
111
+ },
112
+ "size instant hold": {
113
+ animations: () => [tween("#el-size", { width: 630, height: 408 }, 0)],
114
+ settles: false,
115
+ },
116
+ } as const;
117
+
118
+ function buildCases(): Case[] {
119
+ const cases: Case[] = [];
120
+ for (const [routeName, route] of Object.entries(ROUTES)) {
121
+ for (const rotation of ROTATIONS) {
122
+ for (const [dropName, drop] of Object.entries({
123
+ shrink: { w: 326, h: 213, x: 60, y: 40 },
124
+ grow: { w: 980, h: 640, x: -120, y: -90 },
125
+ "near zero": { w: 12, h: 8, x: 200, y: 160 },
126
+ "aspect flip": { w: 900, h: 90, x: 10, y: 10 },
127
+ })) {
128
+ cases.push({
129
+ name: `${routeName} / rotation ${rotation} / ${dropName}`,
130
+ box: { w: 630, h: 408 },
131
+ base: { x: 40, y: 25 },
132
+ liveScale: { x: 1, y: 1 },
133
+ rotation,
134
+ drop,
135
+ animations: route.animations,
136
+ settles: route.settles,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ return cases;
142
+ }
143
+
144
+ const CASES = buildCases();
145
+
146
+ /** The scale and size the run committed, read at the playhead. */
147
+ function committed(calls: unknown[][]) {
148
+ let scale: { x: number; y: number } | null = null;
149
+ let size: { w: number; h: number } | null = null;
150
+ const take = (source: Props | undefined) => {
151
+ if (!source) return;
152
+ const sx = source.scaleX ?? source.scale;
153
+ const sy = source.scaleY ?? source.scale;
154
+ if (sx != null && sy != null) scale = { x: sx, y: sy };
155
+ if (source.width != null && source.height != null) {
156
+ size = { w: source.width, h: source.height };
157
+ }
158
+ };
159
+ for (const call of calls) {
160
+ const mutation = call[1] as {
161
+ properties?: Props;
162
+ percentage?: number;
163
+ keyframes?: Array<{ percentage: number; properties: Props }>;
164
+ };
165
+ if (mutation.keyframes) {
166
+ for (const frame of mutation.keyframes) if (frame.percentage === 0) take(frame.properties);
167
+ continue;
168
+ }
169
+ if (mutation.percentage != null && mutation.percentage !== 0) continue;
170
+ take(mutation.properties);
171
+ }
172
+ return { scale, size };
173
+ }
174
+
175
+ /** The element as the gesture leaves it: drafted box, base pose, live pose. */
176
+ function mountCase(testCase: Case, live: Pose) {
177
+ const el = document.createElement("div");
178
+ el.id = "el";
179
+ el.setAttribute("data-hf-studio-original-box-width", String(testCase.box.w));
180
+ el.setAttribute("data-hf-studio-original-box-height", String(testCase.box.h));
181
+ el.setAttribute("data-hf-drag-gsap-base-x", String(testCase.base.x));
182
+ el.setAttribute("data-hf-drag-gsap-base-y", String(testCase.base.y));
183
+ el.setAttribute("data-hf-studio-box-size", "true");
184
+ el.style.width = `${testCase.drop.w}px`;
185
+ el.style.height = `${testCase.drop.h}px`;
186
+ document.body.append(el);
187
+
188
+ el.getBoundingClientRect = () => {
189
+ const w = Number.parseFloat(el.style.width) || live.box.w;
190
+ const h = Number.parseFloat(el.style.height) || live.box.h;
191
+ const rect = renderRect({ ...live, box: { w, h } }, testCase.rotation);
192
+ return { ...rect, width: rect.w, height: rect.h } as unknown as DOMRect;
193
+ };
194
+ const gsap = {
195
+ set: (_target: Element, vars: Props) => {
196
+ if (vars.x != null) live.pos.x = vars.x;
197
+ if (vars.y != null) live.pos.y = vars.y;
198
+ if (vars.scaleX != null) live.scale.x = vars.scaleX;
199
+ if (vars.scaleY != null) live.scale.y = vars.scaleY;
200
+ },
201
+ getProperty: (_target: Element, prop: string) =>
202
+ ({
203
+ scaleX: live.scale.x,
204
+ scaleY: live.scale.y,
205
+ x: live.pos.x,
206
+ y: live.pos.y,
207
+ rotation: testCase.rotation,
208
+ })[prop] ?? 0,
209
+ };
210
+ Object.assign(window, { gsap });
211
+ const iframe = {
212
+ contentWindow: { gsap, __timelines: { main: { getChildren: () => [] } } },
213
+ contentDocument: document,
214
+ } as unknown as HTMLIFrameElement;
215
+ return { el, iframe };
216
+ }
217
+
218
+ /** One run: mount the case, drive the intercept, hand the result to the judge. */
219
+ async function runCase(testCase: Case): Promise<string[]> {
220
+ document.body.innerHTML = "";
221
+ usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null });
222
+ const live: Pose = {
223
+ box: { ...testCase.box },
224
+ pos: { x: testCase.drop.x, y: testCase.drop.y },
225
+ scale: { ...testCase.liveScale },
226
+ };
227
+ const { el, iframe } = mountCase(testCase, live);
228
+ const dropped = el.getBoundingClientRect();
229
+ const animations = testCase.animations();
230
+ const commitMutation = vi.fn();
231
+
232
+ const outcome = await tryGsapResizeIntercept(
233
+ { id: "el", selector: "#el", element: el } as DomEditSelection,
234
+ { width: testCase.drop.w, height: testCase.drop.h },
235
+ animations,
236
+ iframe,
237
+ commitMutation as never,
238
+ async () => animations,
239
+ );
240
+
241
+ const { scale, size } = committed(commitMutation.mock.calls);
242
+ const settled = renderRect(
243
+ { box: size ?? testCase.box, pos: { ...live.pos }, scale: scale ?? testCase.liveScale },
244
+ testCase.rotation,
245
+ );
246
+ const owns = outcome.status === "persisted" && outcome.ownsDragOffset === true;
247
+ return judge(testCase, dropped, settled, owns);
248
+ }
249
+
250
+ /**
251
+ * What the run got wrong, if anything. 1px: position rounds to whole pixels and
252
+ * scale keeps three decimals.
253
+ */
254
+ function judge(
255
+ testCase: Case,
256
+ dropped: DOMRect,
257
+ settled: { x: number; y: number; w: number; h: number },
258
+ owns: boolean,
259
+ ): string[] {
260
+ const off = (a: number, b: number) => Math.abs(a - b) > 1;
261
+ if (off(settled.w, dropped.width) || off(settled.h, dropped.height)) {
262
+ return [
263
+ `${testCase.name} — box ${settled.w.toFixed(1)}x${settled.h.toFixed(1)}` +
264
+ `, dropped ${dropped.width.toFixed(1)}x${dropped.height.toFixed(1)}`,
265
+ ];
266
+ }
267
+ // Claiming the drop point is only honest for the routes that settle it. The
268
+ // size routes commit width and height and move nothing, so the drag still owns
269
+ // the anchor — and a run that claims otherwise makes the caller withhold an
270
+ // offset nobody writes. The position check below cannot see that on its own:
271
+ // the fixture's live pose starts at the drop, which is where the gesture
272
+ // leaves it, so a size route trivially "lands" there.
273
+ if (owns !== testCase.settles) {
274
+ return [`${testCase.name} — ownsDragOffset ${owns}, expected ${testCase.settles}`];
275
+ }
276
+ if (owns && (off(settled.x, dropped.x) || off(settled.y, dropped.y))) {
277
+ return [
278
+ `${testCase.name} — landed ${settled.x.toFixed(1)},${settled.y.toFixed(1)}` +
279
+ `, dropped ${dropped.x.toFixed(1)},${dropped.y.toFixed(1)}`,
280
+ ];
281
+ }
282
+ return [];
283
+ }
284
+
285
+ it(`sweeps ${CASES.length} rotations and drops for the box the user dropped`, async () => {
286
+ const failures: string[] = [];
287
+ for (const testCase of CASES) failures.push(...(await runCase(testCase)));
288
+ expect(failures).toEqual([]);
289
+ });
@@ -98,7 +98,7 @@ it("updates a duration-zero size hold in place instead of converting it to keyfr
98
98
  commitMutation,
99
99
  );
100
100
 
101
- expect(handled).toEqual({ status: "persisted" });
101
+ expect(handled).toMatchObject({ status: "persisted" });
102
102
  expect(commitMutation).toHaveBeenCalledTimes(1);
103
103
  expect(commitMutation.mock.calls[0]![1]).toEqual({
104
104
  type: "update-properties",
@@ -246,7 +246,7 @@ async function runResize(
246
246
  commitMutation as never,
247
247
  async () => [keyframedScaleFixture()],
248
248
  );
249
- expect(handled).toEqual({ status: "persisted" });
249
+ expect(handled).toMatchObject({ status: "persisted" });
250
250
  return committed;
251
251
  }
252
252