@hyperframes/studio 0.6.105 → 0.6.106

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/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-CKd39gmy.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-BITwbxi-.css">
8
+ <script type="module" crossorigin src="/assets/index-koqvg-_0.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BrhJl2JY.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.105",
3
+ "version": "0.6.106",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,9 +33,9 @@
33
33
  "@phosphor-icons/react": "^2.1.10",
34
34
  "bpm-detective": "^2.0.5",
35
35
  "mediabunny": "^1.45.3",
36
- "@hyperframes/core": "0.6.105",
37
- "@hyperframes/player": "0.6.105",
38
- "@hyperframes/sdk": "0.6.105"
36
+ "@hyperframes/core": "0.6.106",
37
+ "@hyperframes/sdk": "0.6.106",
38
+ "@hyperframes/player": "0.6.106"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/react": "19",
@@ -49,7 +49,7 @@
49
49
  "vite": "^6.4.2",
50
50
  "vitest": "^3.2.4",
51
51
  "zustand": "^5.0.0",
52
- "@hyperframes/producer": "0.6.105"
52
+ "@hyperframes/producer": "0.6.106"
53
53
  },
54
54
  "peerDependencies": {
55
55
  "react": "19",
@@ -87,6 +87,7 @@ export function StudioRightPanel({
87
87
  commitAnimatedProperty,
88
88
  handleSetArcPath,
89
89
  handleUpdateArcSegment,
90
+ handleUnroll,
90
91
  handleGsapAddKeyframe,
91
92
  handleGsapRemoveKeyframe,
92
93
  handleGsapConvertToKeyframes,
@@ -215,6 +216,7 @@ export function StudioRightPanel({
215
216
  onSeekToTime={(t) => usePlayerStore.getState().requestSeek(t)}
216
217
  onSetArcPath={handleSetArcPath}
217
218
  onUpdateArcSegment={handleUpdateArcSegment}
219
+ onUnroll={handleUnroll}
218
220
  recordingState={recordingState}
219
221
  recordingDuration={recordingDuration}
220
222
  onToggleRecording={onToggleRecording}
@@ -18,7 +18,8 @@ import {
18
18
  import { buildTweenSummary } from "./gsapAnimationHelpers";
19
19
  import { EaseCurveSection } from "./EaseCurveSection";
20
20
  import { ArcPathControls } from "./ArcPathControls";
21
- import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
21
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
22
+ import { ComputedTweenNotice } from "./ComputedTweenNotice";
22
23
  import { P } from "./panelTokens";
23
24
  const BOOLEAN_PROPS = new Set(["visibility"]);
24
25
  const STRING_PROPS = new Set(["filter", "clipPath"]);
@@ -235,31 +236,9 @@ function parseNumericOrString(raw: string): number | string {
235
236
  return Number.isFinite(num) ? num : raw;
236
237
  }
237
238
 
238
- interface AnimationCardProps {
239
+ interface AnimationCardProps extends GsapAnimationEditCallbacks {
239
240
  animation: GsapAnimation;
240
241
  defaultExpanded: boolean;
241
- onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
242
- onUpdateMeta: (
243
- animationId: string,
244
- updates: { duration?: number; ease?: string; position?: number },
245
- ) => void;
246
- onDeleteAnimation: (animationId: string) => void;
247
- onAddProperty: (animationId: string, property: string) => void;
248
- onRemoveProperty: (animationId: string, property: string) => void;
249
- onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
250
- onAddFromProperty?: (animationId: string, property: string) => void;
251
- onRemoveFromProperty?: (animationId: string, property: string) => void;
252
- onLivePreview?: (property: string, value: number | string) => void;
253
- onLivePreviewEnd?: () => void;
254
- onSetArcPath?: (
255
- animationId: string,
256
- config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
257
- ) => void;
258
- onUpdateArcSegment?: (
259
- animationId: string,
260
- segmentIndex: number,
261
- update: Partial<ArcPathSegment>,
262
- ) => void;
263
242
  }
264
243
 
265
244
  // fallow-ignore-next-line complexity
@@ -278,6 +257,7 @@ export const AnimationCard = memo(function AnimationCard({
278
257
  onLivePreviewEnd,
279
258
  onSetArcPath,
280
259
  onUpdateArcSegment,
260
+ onUnroll,
281
261
  }: AnimationCardProps) {
282
262
  const [expanded, setExpanded] = useState(defaultExpanded);
283
263
  const [addingProp, setAddingProp] = useState(false);
@@ -397,6 +377,10 @@ export const AnimationCard = memo(function AnimationCard({
397
377
  {expanded && (
398
378
  <div className="pt-2">
399
379
  <div className="space-y-3">
380
+ <ComputedTweenNotice
381
+ provenance={animation.provenance}
382
+ onUnroll={onUnroll ? () => onUnroll(animation.id) : undefined}
383
+ />
400
384
  <div className="flex items-start gap-2">
401
385
  <div className="flex-1">
402
386
  <p className="text-[10px] leading-relaxed text-neutral-400 italic">{summary}</p>
@@ -0,0 +1,40 @@
1
+ import { editabilityForProvenance, type GsapProvenance } from "@hyperframes/core/gsap-parser-acorn";
2
+
3
+ /**
4
+ * Notice shown for computed tweens: helper/loop tweens offer an "unroll to
5
+ * edit" action; runtime-computed values point to the Code tab. Literal tweens
6
+ * render nothing.
7
+ */
8
+ export function ComputedTweenNotice({
9
+ provenance,
10
+ onUnroll,
11
+ }: {
12
+ provenance?: GsapProvenance;
13
+ onUnroll?: () => void;
14
+ }) {
15
+ const editability = editabilityForProvenance(provenance);
16
+ if (editability === "direct") return null;
17
+ if (editability === "source") {
18
+ return (
19
+ <div className="rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
20
+ Computed value — edit it in the Code tab.
21
+ </div>
22
+ );
23
+ }
24
+ const source = provenance?.fn ? `${provenance.fn}()` : "a loop";
25
+ return (
26
+ <div className="flex items-center justify-between gap-2 rounded-md border border-neutral-800 bg-neutral-900/50 px-2 py-1.5 text-[9px] text-neutral-400">
27
+ <span>Generated by {source} — not directly editable.</span>
28
+ {onUnroll && (
29
+ <button
30
+ type="button"
31
+ onClick={onUnroll}
32
+ className="flex-shrink-0 rounded px-1.5 py-0.5 text-[9px] font-medium text-panel-accent hover:bg-neutral-800"
33
+ title="Rewrite the helper/loop into explicit tweens so this keyframe edits directly"
34
+ >
35
+ Unroll to edit
36
+ </button>
37
+ )}
38
+ </div>
39
+ );
40
+ }
@@ -1,37 +1,16 @@
1
1
  import { memo, useState } from "react";
2
- import type { ArcPathSegment, GsapAnimation } from "@hyperframes/core/gsap-parser";
2
+ import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
3
3
  import { Film } from "../../icons/SystemIcons";
4
4
  import { Section } from "./propertyPanelPrimitives";
5
5
  import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants";
6
6
  import { AnimationCard } from "./AnimationCard";
7
+ import type { GsapAnimationEditCallbacks } from "./gsapAnimationCallbacks";
7
8
 
8
- interface GsapAnimationSectionProps {
9
+ interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks {
9
10
  animations: GsapAnimation[];
10
11
  multipleTimelines?: boolean;
11
12
  unsupportedTimelinePattern?: boolean;
12
- onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
13
- onUpdateMeta: (
14
- animationId: string,
15
- updates: { duration?: number; ease?: string; position?: number },
16
- ) => void;
17
- onDeleteAnimation: (animationId: string) => void;
18
- onAddProperty: (animationId: string, property: string) => void;
19
- onRemoveProperty: (animationId: string, property: string) => void;
20
- onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
21
- onAddFromProperty?: (animationId: string, property: string) => void;
22
- onRemoveFromProperty?: (animationId: string, property: string) => void;
23
13
  onAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void;
24
- onLivePreview?: (property: string, value: number | string) => void;
25
- onLivePreviewEnd?: () => void;
26
- onSetArcPath?: (
27
- animationId: string,
28
- config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
29
- ) => void;
30
- onUpdateArcSegment?: (
31
- animationId: string,
32
- segmentIndex: number,
33
- update: Partial<ArcPathSegment>,
34
- ) => void;
35
14
  }
36
15
 
37
16
  export const GsapAnimationSection = memo(function GsapAnimationSection({
@@ -51,6 +30,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
51
30
  onLivePreviewEnd,
52
31
  onSetArcPath,
53
32
  onUpdateArcSegment,
33
+ onUnroll,
54
34
  }: GsapAnimationSectionProps) {
55
35
  const [addMenuOpen, setAddMenuOpen] = useState(false);
56
36
 
@@ -88,6 +68,7 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
88
68
  onLivePreviewEnd={onLivePreviewEnd}
89
69
  onSetArcPath={onSetArcPath}
90
70
  onUpdateArcSegment={onUpdateArcSegment}
71
+ onUnroll={onUnroll}
91
72
  />
92
73
  ))}
93
74
 
@@ -74,6 +74,7 @@ export const PropertyPanel = memo(function PropertyPanel({
74
74
  onAddGsapAnimation,
75
75
  onSetArcPath,
76
76
  onUpdateArcSegment,
77
+ onUnroll,
77
78
  onAddKeyframe,
78
79
  onRemoveKeyframe,
79
80
  onConvertToKeyframes,
@@ -531,6 +532,7 @@ export const PropertyPanel = memo(function PropertyPanel({
531
532
  onAddAnimation={onAddGsapAnimation}
532
533
  onSetArcPath={onSetArcPath}
533
534
  onUpdateArcSegment={onUpdateArcSegment}
535
+ onUnroll={onUnroll}
534
536
  />
535
537
  )}
536
538
 
@@ -0,0 +1,33 @@
1
+ import type { ArcPathSegment } from "@hyperframes/core/gsap-parser";
2
+
3
+ /**
4
+ * Edit callbacks shared by GsapAnimationSection and each AnimationCard it
5
+ * renders. Extracted so the two prop interfaces don't duplicate the (large)
6
+ * signatures the section forwards straight through to the card.
7
+ */
8
+ export interface GsapAnimationEditCallbacks {
9
+ onUpdateProperty: (animationId: string, property: string, value: number | string) => void;
10
+ onUpdateMeta: (
11
+ animationId: string,
12
+ updates: { duration?: number; ease?: string; position?: number },
13
+ ) => void;
14
+ onDeleteAnimation: (animationId: string) => void;
15
+ onAddProperty: (animationId: string, property: string) => void;
16
+ onRemoveProperty: (animationId: string, property: string) => void;
17
+ onUpdateFromProperty?: (animationId: string, property: string, value: number | string) => void;
18
+ onAddFromProperty?: (animationId: string, property: string) => void;
19
+ onRemoveFromProperty?: (animationId: string, property: string) => void;
20
+ onLivePreview?: (property: string, value: number | string) => void;
21
+ onLivePreviewEnd?: () => void;
22
+ onSetArcPath?: (
23
+ animationId: string,
24
+ config: { enabled: boolean; autoRotate?: boolean | number; segments?: ArcPathSegment[] },
25
+ ) => void;
26
+ onUpdateArcSegment?: (
27
+ animationId: string,
28
+ segmentIndex: number,
29
+ update: Partial<ArcPathSegment>,
30
+ ) => void;
31
+ /** Unroll a computed (helper/loop) tween into literal tweens so it edits directly. */
32
+ onUnroll?: (animationId: string) => void;
33
+ }
@@ -56,6 +56,8 @@ export interface PropertyPanelProps {
56
56
  segmentIndex: number,
57
57
  update: Partial<import("@hyperframes/core/gsap-parser").ArcPathSegment>,
58
58
  ) => void;
59
+ /** Unroll computed (helper/loop) tweens into literal tweens for direct editing. */
60
+ onUnroll?: (animationId: string) => void;
59
61
  onAddKeyframe?: (
60
62
  animationId: string,
61
63
  percentage: number,
@@ -56,6 +56,7 @@ export interface DomEditActionsValue extends Pick<
56
56
  | "commitAnimatedProperty"
57
57
  | "handleSetArcPath"
58
58
  | "handleUpdateArcSegment"
59
+ | "handleUnroll"
59
60
  | "invalidateGsapCache"
60
61
  | "previewIframeRef"
61
62
  | "commitMutation"
@@ -160,6 +161,7 @@ export function DomEditProvider({
160
161
  commitAnimatedProperty,
161
162
  handleSetArcPath,
162
163
  handleUpdateArcSegment,
164
+ handleUnroll,
163
165
  invalidateGsapCache,
164
166
  previewIframeRef,
165
167
  commitMutation,
@@ -229,6 +231,7 @@ export function DomEditProvider({
229
231
  commitAnimatedProperty,
230
232
  handleSetArcPath,
231
233
  handleUpdateArcSegment,
234
+ handleUnroll,
232
235
  invalidateGsapCache,
233
236
  previewIframeRef,
234
237
  commitMutation: stableCommitMutation,
@@ -284,6 +287,7 @@ export function DomEditProvider({
284
287
  commitAnimatedProperty,
285
288
  handleSetArcPath,
286
289
  handleUpdateArcSegment,
290
+ handleUnroll,
287
291
  invalidateGsapCache,
288
292
  previewIframeRef,
289
293
  stableCommitMutation,
@@ -160,14 +160,93 @@ async function commitFlatViaKeyframes(
160
160
  properties: Record<string, number>,
161
161
  callbacks: GsapDragCommitCallbacks,
162
162
  beforeReload?: () => void,
163
+ iframe?: HTMLIFrameElement | null,
164
+ selector?: string,
163
165
  ): Promise<void> {
166
+ const ct = usePlayerStore.getState().currentTime;
167
+ const ts = resolveTweenStart(anim);
168
+ const td = resolveTweenDuration(anim);
169
+ const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
170
+
171
+ // Read the runtime position at the tween's start time so the 0% keyframe
172
+ // captures the actual interpolated value (e.g. x=300 after a preceding slide),
173
+ // not the identity value (x=0) that a blind convert would produce.
174
+ const resolvedFromValues: Record<string, number | string> = {};
175
+ if (iframe && selector && ts !== null) {
176
+ try {
177
+ const iframeWin = iframe.contentWindow as any;
178
+ const gsapLib = iframeWin?.gsap;
179
+ const el = iframe.contentDocument?.querySelector(selector);
180
+ const timelines = iframeWin?.__timelines;
181
+ const mainTl = timelines ? (Object.values(timelines)[0] as any) : null;
182
+ if (gsapLib && el && mainTl?.seek) {
183
+ mainTl.seek(ts);
184
+ for (const key of Object.keys(properties)) {
185
+ const v = Number(gsapLib.getProperty(el, key));
186
+ if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
187
+ }
188
+ mainTl.seek(ct);
189
+ }
190
+ } catch {
191
+ /* iframe access failed — fall back to identity values */
192
+ }
193
+ }
194
+
195
+ if (outsideRange && ts !== null) {
196
+ // Outside the tween's range: add a brand new keyframed tween at the drag
197
+ // time instead of extending/replacing the existing one. This keeps all
198
+ // existing tweens untouched and creates a clean hold at the dragged position.
199
+ const tweenEnd = ts + td;
200
+ const holdStart = ct > tweenEnd ? tweenEnd : ct;
201
+ const holdEnd = ct > tweenEnd ? ct : ts;
202
+ const holdDur = Math.max(0.01, holdEnd - holdStart);
203
+ const kfs =
204
+ ct > tweenEnd
205
+ ? [
206
+ { percentage: 0, properties: resolvedFromValues },
207
+ { percentage: 100, properties },
208
+ ]
209
+ : [
210
+ { percentage: 0, properties },
211
+ { percentage: 100, properties: resolvedFromValues },
212
+ ];
213
+ console.log(
214
+ "[drag:5] outside range — adding new tween",
215
+ JSON.stringify({
216
+ ct,
217
+ ts,
218
+ td,
219
+ holdStart: roundTo3(holdStart),
220
+ holdDur: roundTo3(holdDur),
221
+ from: resolvedFromValues,
222
+ to: properties,
223
+ }),
224
+ );
225
+ await callbacks.commitMutation(
226
+ selection,
227
+ {
228
+ type: "add-with-keyframes",
229
+ targetSelector: anim.targetSelector,
230
+ position: roundTo3(holdStart),
231
+ duration: roundTo3(holdDur),
232
+ keyframes: kfs,
233
+ },
234
+ { label: "Move layer (new keyframe)", softReload: true, beforeReload },
235
+ );
236
+ return;
237
+ }
238
+
239
+ // Inside range: convert the flat tween to keyframes, then add at current %.
164
240
  const coalesceKey = `gsap:convert-drag:${anim.id}`;
165
241
  await callbacks.commitMutation(
166
242
  selection,
167
- { type: "convert-to-keyframes", animationId: anim.id },
243
+ {
244
+ type: "convert-to-keyframes",
245
+ animationId: anim.id,
246
+ ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
247
+ },
168
248
  { label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
169
249
  );
170
-
171
250
  const pct = computeCurrentPercentage(selection, anim);
172
251
 
173
252
  await callbacks.commitMutation(
@@ -350,6 +429,14 @@ export async function commitGsapPositionFromDrag(
350
429
  );
351
430
  }
352
431
  } else {
353
- await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset);
432
+ await commitFlatViaKeyframes(
433
+ selection,
434
+ anim,
435
+ { x: newX, y: newY },
436
+ callbacks,
437
+ restoreOffset,
438
+ iframe,
439
+ selector,
440
+ );
354
441
  }
355
442
  }
@@ -73,7 +73,11 @@ function findGsapPositionAnimation(
73
73
  else if (a.targetSelector.includes(",")) score -= 5;
74
74
  const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0);
75
75
  const dur = a.duration ?? 0;
76
- if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4;
76
+ if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 50;
77
+ else
78
+ score -= Math.round(
79
+ Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5,
80
+ );
77
81
  return { anim: a, score };
78
82
  });
79
83
  scored.sort((a, b) => b.score - a.score);
@@ -84,6 +88,34 @@ function findGsapPositionAnimation(
84
88
 
85
89
  // ── Property-group tween resolution ───────────────────────────────────────
86
90
 
91
+ /**
92
+ * From a set of candidate tweens, pick the one whose time range is closest to
93
+ * the current playhead. A tween that *contains* the playhead wins outright;
94
+ * otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or
95
+ * extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a
96
+ * gesture-recorded tween beats a stub when both are equidistant).
97
+ */
98
+ function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null {
99
+ if (anims.length <= 1) return anims[0] ?? null;
100
+ const ct = usePlayerStore.getState().currentTime;
101
+ return anims.reduce((best, a) => {
102
+ const s = resolveTweenStart(a) ?? 0;
103
+ const e = s + resolveTweenDuration(a);
104
+ const dist = ct >= s && ct <= e ? 0 : Math.min(Math.abs(ct - s), Math.abs(ct - e));
105
+ const bestS = resolveTweenStart(best) ?? 0;
106
+ const bestE = bestS + resolveTweenDuration(best);
107
+ const bestDist =
108
+ ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE));
109
+ if (dist < bestDist) return a;
110
+ if (
111
+ dist === bestDist &&
112
+ (a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0)
113
+ )
114
+ return a;
115
+ return best;
116
+ });
117
+ }
118
+
87
119
  /**
88
120
  * Find the tween for a given property group, splitting a legacy mixed tween
89
121
  * if necessary. Returns the resolved animation or null if none exists.
@@ -101,15 +133,10 @@ async function resolveGroupTween(
101
133
  commitMutation: GsapDragCommitCallbacks["commitMutation"],
102
134
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
103
135
  ): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> {
104
- // 1. Already-split group tween — prefer the one with the most keyframes
105
- // to avoid targeting a stub when a gesture-recorded tween also exists.
136
+ // 1. Already-split group tween — pick the one closest to the current
137
+ // playhead so a drag at t=6s edits the tween at 4s, not the one at 1.5s.
106
138
  const groupAnims = animations.filter((a) => a.propertyGroup === group);
107
- const groupAnim =
108
- groupAnims.length > 1
109
- ? groupAnims.sort(
110
- (a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0),
111
- )[0]
112
- : (groupAnims[0] ?? null);
139
+ const groupAnim = pickClosestToPlayhead(groupAnims);
113
140
  if (groupAnim) return { anim: groupAnim, animations };
114
141
 
115
142
  // 2. Legacy mixed tween — split it, then re-fetch
@@ -171,9 +198,19 @@ export async function tryGsapDragIntercept(
171
198
  fetchFallbackAnimations?: () => Promise<GsapAnimation[]>,
172
199
  ): Promise<boolean> {
173
200
  const selector = selectorFromSelection(selection);
174
- if (!selector) return false;
201
+ console.log(
202
+ "[drag:4] tryGsapDragIntercept",
203
+ JSON.stringify({
204
+ sel: selection.id,
205
+ selector,
206
+ animCount: animations.length,
207
+ groups: animations.map((a) => a.propertyGroup).filter(Boolean),
208
+ }),
209
+ );
210
+ if (!selector) {
211
+ return false;
212
+ }
175
213
 
176
- // Resolve the position-group tween, splitting legacy mixed tweens if needed.
177
214
  const resolved = await resolveGroupTween(
178
215
  "position",
179
216
  animations,
@@ -181,26 +218,40 @@ export async function tryGsapDragIntercept(
181
218
  commitMutation,
182
219
  fetchFallbackAnimations,
183
220
  );
221
+ console.log(
222
+ "[drag:4] resolveGroupTween('position') →",
223
+ resolved
224
+ ? JSON.stringify({ id: resolved.anim.id, group: resolved.anim.propertyGroup })
225
+ : "null",
226
+ );
184
227
 
185
- // Fallback: use the legacy scoring heuristic for compositions that don't
186
- // have group-tagged tweens at all (e.g. hand-written scripts).
187
228
  let posAnim = resolved?.anim ?? null;
188
229
  if (!posAnim) {
189
230
  posAnim = findGsapPositionAnimation(animations, selector);
190
231
  if (!posAnim && fetchFallbackAnimations) {
191
232
  const fresh = await fetchFallbackAnimations();
192
233
  posAnim = findGsapPositionAnimation(fresh, selector);
234
+ console.log(
235
+ "[drag:4] findGsapPositionAnimation (fetched) →",
236
+ posAnim ? posAnim.id : "null",
237
+ "freshCount:",
238
+ fresh.length,
239
+ );
193
240
  }
194
241
  }
195
- if (!posAnim) return false;
196
-
197
- // Keyframe writes at 0%/100% when outside the tween range. Acceptable
198
- // trade-off — CSS path must NEVER touch GSAP-targeted elements because
199
- // changing the CSS offset corrupts all existing keyframes (baked mismatch).
242
+ if (!posAnim) {
243
+ return false;
244
+ }
200
245
 
201
246
  const gsapPos = readGsapPositionFromIframe(iframe, selector);
202
- if (!gsapPos) return false;
247
+ if (!gsapPos) {
248
+ return false;
249
+ }
203
250
 
251
+ console.log(
252
+ "[drag:4] committing GSAP position drag",
253
+ JSON.stringify({ posAnimId: posAnim.id, gsapPos }),
254
+ );
204
255
  await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, {
205
256
  commitMutation,
206
257
  fetchAnimations: fetchFallbackAnimations,
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { arcPathFromMotionPathValue } from "./gsapRuntimeKeyframes";
3
+
4
+ describe("arcPathFromMotionPathValue", () => {
5
+ it("builds arc config from object form { path, curviness }", () => {
6
+ const arc = arcPathFromMotionPathValue({
7
+ path: [
8
+ { x: 0, y: 0 },
9
+ { x: 100, y: -50 },
10
+ { x: 200, y: 0 },
11
+ { x: 300, y: 80 },
12
+ ],
13
+ curviness: 2,
14
+ });
15
+ expect(arc?.enabled).toBe(true);
16
+ expect(arc?.segments).toHaveLength(3); // 4 waypoints → 3 segments
17
+ expect(arc?.segments.every((s) => s.curviness === 2)).toBe(true);
18
+ });
19
+
20
+ it("builds arc config from bare array form (default curviness 1)", () => {
21
+ const arc = arcPathFromMotionPathValue([
22
+ { x: 0, y: 0 },
23
+ { x: 50, y: 50 },
24
+ ]);
25
+ expect(arc?.enabled).toBe(true);
26
+ expect(arc?.segments).toHaveLength(1);
27
+ expect(arc?.segments[0]!.curviness).toBe(1);
28
+ });
29
+
30
+ it("carries autoRotate", () => {
31
+ const arc = arcPathFromMotionPathValue({
32
+ path: [
33
+ { x: 0, y: 0 },
34
+ { x: 10, y: 10 },
35
+ ],
36
+ autoRotate: true,
37
+ });
38
+ expect(arc?.autoRotate).toBe(true);
39
+ });
40
+
41
+ it("returns undefined for fewer than 2 points, missing path, or string path", () => {
42
+ expect(arcPathFromMotionPathValue({ path: [{ x: 0, y: 0 }] })).toBeUndefined();
43
+ expect(arcPathFromMotionPathValue({ curviness: 2 })).toBeUndefined();
44
+ expect(arcPathFromMotionPathValue({ path: "M0 0 L10 10" })).toBeUndefined();
45
+ expect(arcPathFromMotionPathValue(null)).toBeUndefined();
46
+ });
47
+ });