@agent-native/core 0.85.6 → 0.85.7

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 (80) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/client/use-pinch-zoom.ts +76 -11
  5. package/corpus/core/src/collab/presence.ts +63 -3
  6. package/corpus/templates/clips/desktop/src/lib/recorder.ts +245 -77
  7. package/corpus/templates/design/.agents/skills/design-generation/SKILL.md +190 -13
  8. package/corpus/templates/design/.agents/skills/design-systems/SKILL.md +16 -4
  9. package/corpus/templates/design/.agents/skills/export-handoff/SKILL.md +46 -3
  10. package/corpus/templates/design/AGENTS.md +33 -1
  11. package/corpus/templates/design/actions/apply-component-prop-edit.ts +20 -73
  12. package/corpus/templates/design/actions/apply-motion-edit.ts +29 -2
  13. package/corpus/templates/design/actions/apply-visual-edit.ts +4 -2
  14. package/corpus/templates/design/actions/connect-localhost.ts +25 -18
  15. package/corpus/templates/design/actions/export-pdf.ts +8 -1
  16. package/corpus/templates/design/actions/export-zip.ts +10 -1
  17. package/corpus/templates/design/actions/get-motion-timeline.ts +26 -20
  18. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +10 -0
  19. package/corpus/templates/design/actions/insert-asset.ts +190 -7
  20. package/corpus/templates/design/actions/open-visual-edit.ts +27 -5
  21. package/corpus/templates/design/actions/present-design-variants.ts +2 -2
  22. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +18 -14
  23. package/corpus/templates/design/actions/run-design-audit.ts +7 -4
  24. package/corpus/templates/design/actions/write-local-file.ts +47 -22
  25. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +221 -66
  26. package/corpus/templates/design/app/components/design/EditPanel.tsx +650 -237
  27. package/corpus/templates/design/app/components/design/LayersPanel.tsx +526 -127
  28. package/corpus/templates/design/app/components/design/MotionDock.tsx +234 -46
  29. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1497 -408
  30. package/corpus/templates/design/app/components/design/StatesPanel.tsx +25 -2
  31. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +640 -72
  32. package/corpus/templates/design/app/components/design/bridge/motion-preview.bridge.ts +605 -0
  33. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +50 -26
  34. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +16 -4
  35. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +90 -103
  36. package/corpus/templates/design/app/components/design/inspector/GradientEditor.tsx +111 -5
  37. package/corpus/templates/design/app/components/design/inspector/ImageFillControls.tsx +13 -2
  38. package/corpus/templates/design/app/components/design/inspector/ScrubInput.tsx +42 -2
  39. package/corpus/templates/design/app/components/design/inspector/scrub-input-utils.ts +11 -2
  40. package/corpus/templates/design/app/components/design/types.ts +16 -0
  41. package/corpus/templates/design/app/components/visual-editor/CanvasCommentPins.tsx +24 -6
  42. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +15 -1
  43. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +77 -0
  44. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  45. package/corpus/templates/design/app/i18n-data.ts +18 -0
  46. package/corpus/templates/design/app/lib/design-import.ts +95 -0
  47. package/corpus/templates/design/app/pages/DesignEditor.tsx +4013 -838
  48. package/corpus/templates/design/changelog/2026-07-03-canvas-interactions-now-match-figma-rotation-aware-resizing-.md +6 -0
  49. package/corpus/templates/design/changelog/2026-07-03-inspector-fixes-mixed-selections-show-mixed-instead-of-wrong.md +6 -0
  50. package/corpus/templates/design/changelog/2026-07-03-keyframe-timeline-works-reliably-drag-keyframes-smoothly-pic.md +6 -0
  51. package/corpus/templates/design/changelog/2026-07-03-layers-panel-matches-figma-top-layer-on-top-drag-with-auto-s.md +6 -0
  52. package/corpus/templates/design/changelog/2026-07-03-much-faster-editor-smooth-dragging-zooming-and-panel-updates.md +6 -0
  53. package/corpus/templates/design/changelog/2026-07-03-pen-tool-refinements-click-the-first-point-to-close-with-a-d.md +6 -0
  54. package/corpus/templates/design/changelog/2026-07-03-text-editing-enter-adds-a-line-break-international-ime-typin.md +6 -0
  55. package/corpus/templates/design/changelog/2026-07-03-undo-and-redo-are-dependable-across-agent-edits-screen-switc.md +6 -0
  56. package/corpus/templates/design/server/lib/design-export.ts +43 -1
  57. package/corpus/templates/design/shared/board-file.ts +25 -5
  58. package/corpus/templates/design/shared/canvas-math.ts +754 -9
  59. package/corpus/templates/design/shared/code-layer.ts +304 -26
  60. package/corpus/templates/design/shared/color-utils.ts +84 -0
  61. package/corpus/templates/design/shared/design-source-capabilities.ts +16 -7
  62. package/corpus/templates/design/shared/motion-compiler.ts +75 -31
  63. package/corpus/templates/design/shared/motion-timeline.ts +335 -0
  64. package/corpus/templates/design/shared/pen-path.ts +200 -23
  65. package/dist/client/use-pinch-zoom.d.ts.map +1 -1
  66. package/dist/client/use-pinch-zoom.js +76 -11
  67. package/dist/client/use-pinch-zoom.js.map +1 -1
  68. package/dist/collab/awareness.d.ts +2 -2
  69. package/dist/collab/awareness.d.ts.map +1 -1
  70. package/dist/collab/presence.d.ts.map +1 -1
  71. package/dist/collab/presence.js +54 -3
  72. package/dist/collab/presence.js.map +1 -1
  73. package/dist/collab/routes.d.ts +2 -2
  74. package/dist/collab/struct-routes.d.ts +1 -1
  75. package/dist/observability/routes.d.ts +3 -3
  76. package/dist/progress/routes.d.ts +1 -1
  77. package/dist/resources/handlers.d.ts +2 -2
  78. package/dist/secrets/routes.d.ts +3 -3
  79. package/dist/server/transcribe-voice.d.ts +1 -1
  80. package/package.json +1 -1
@@ -27,11 +27,15 @@ import {
27
27
  MOTION_PROPERTY_PRESETS,
28
28
  createMotionTrackFromPreset,
29
29
  hasTrackFor,
30
+ sampleMotionKeyframesAt,
31
+ sortMotionKeyframes,
32
+ upsertMotionKeyframeAtTime,
30
33
  } from "@shared/motion-timeline";
31
34
  import {
32
35
  IconPlayerPlay,
33
36
  IconPlayerPause,
34
37
  IconPlayerStop,
38
+ IconCheck,
35
39
  IconDiamond,
36
40
  IconChevronDown,
37
41
  IconChevronRight,
@@ -49,6 +53,7 @@ import {
49
53
  type PointerEvent as ReactPointerEvent,
50
54
  type TransitionEvent as ReactTransitionEvent,
51
55
  } from "react";
56
+ import { toast } from "sonner";
52
57
 
53
58
  import { Button } from "@/components/ui/button";
54
59
  import {
@@ -123,8 +128,23 @@ export interface MotionDockProps {
123
128
  onAutoKeyframeChange?: (enabled: boolean) => void;
124
129
  /** Controlled playhead position, normalized to [0, 1]. */
125
130
  playhead?: number;
126
- /** Called whenever the playhead moves. */
131
+ /**
132
+ * Called when the playhead position COMMITS (pause/stop, scrub end, reset)
133
+ * — deliberately not on every rAF tick/scrub frame, so the parent is never
134
+ * re-rendered at 60fps. Continuous preview stays inside the dock.
135
+ */
127
136
  onPlayheadChange?: (t: number) => void;
137
+ /**
138
+ * Parent-owned mirror of the LIVE playhead position, updated on every rAF
139
+ * tick and scrub frame (not just at commit points). The dock only notifies
140
+ * the parent's state at commit points to avoid 60fps re-renders, but
141
+ * auto-keyframe needs the true current position — an inspector edit made
142
+ * mid-playback must key at where the playhead actually is, not at the last
143
+ * committed time. Writing to a ref keeps that value fresh without
144
+ * re-rendering the editor. Cleared back to the committed value on
145
+ * pause/stop/scrub-end so a later read outside playback isn't stale.
146
+ */
147
+ livePlayheadRef?: React.MutableRefObject<number | null>;
128
148
  /**
129
149
  * The currently-selected canvas element, if any. Required to create the FIRST
130
150
  * track for a layer: the picker animates this node's
@@ -151,6 +171,7 @@ export function MotionDock({
151
171
  onAutoKeyframeChange,
152
172
  playhead: playheadProp,
153
173
  onPlayheadChange,
174
+ livePlayheadRef,
154
175
  selectedTarget = null,
155
176
  }: MotionDockProps) {
156
177
  // Controlled / uncontrolled open state.
@@ -164,15 +185,24 @@ export function MotionDock({
164
185
  [onOpenChange],
165
186
  );
166
187
 
167
- // Playhead position: normalised [0, 1].
188
+ // Playhead position: normalised [0, 1]. High-frequency updates (rAF playback
189
+ // ticks, ruler scrubbing) stay INTERNAL to the dock; the parent is only
190
+ // notified at commit points (pause/stop, scrub end, reset) so a 60fps
191
+ // playhead never re-renders the whole editor page.
168
192
  const [playhead, setPlayhead] = useState(playheadProp ?? 0);
193
+ const playheadRef = useRef(playheadProp ?? 0);
169
194
  const [playing, setPlaying] = useState(false);
170
195
  const playRafRef = useRef<number | null>(null);
171
196
  const playStartRef = useRef<{ wallMs: number; startT: number } | null>(null);
172
197
  useEffect(() => {
173
198
  if (playheadProp === undefined) return;
174
- setPlayhead(Math.max(0, Math.min(1, playheadProp)));
175
- }, [playheadProp]);
199
+ const clamped = Math.max(0, Math.min(1, playheadProp));
200
+ playheadRef.current = clamped;
201
+ // Keep the live-playhead mirror seeded with the committed position so a
202
+ // read outside playback/scrub returns the current time, not a stale one.
203
+ if (livePlayheadRef) livePlayheadRef.current = clamped;
204
+ setPlayhead(clamped);
205
+ }, [livePlayheadRef, playheadProp]);
176
206
 
177
207
  // Auto-keyframe mode: inspector/style edits create keyframes at the playhead.
178
208
  const [autoKeyframeInternal, setAutoKeyframeInternal] = useState(false);
@@ -188,13 +218,24 @@ export function MotionDock({
188
218
  },
189
219
  [autoKeyframe, onAutoKeyframeChange],
190
220
  );
191
- const setPlayheadValue = useCallback(
221
+ /** Internal high-frequency playhead update — does NOT notify the parent. */
222
+ const setPlayheadLocal = useCallback(
192
223
  (next: number) => {
224
+ playheadRef.current = next;
225
+ // Mirror the LIVE position into the parent-owned ref so auto-keyframe
226
+ // reads the true current playhead during playback/scrub, not the last
227
+ // committed time. Updating a ref never re-renders, so this stays cheap
228
+ // at 60fps.
229
+ if (livePlayheadRef) livePlayheadRef.current = next;
193
230
  setPlayhead(next);
194
- onPlayheadChange?.(next);
195
231
  },
196
- [onPlayheadChange],
232
+ [livePlayheadRef],
197
233
  );
234
+ /** Commit the current playhead to the parent (pause, scrub end, reset). */
235
+ const commitPlayhead = useCallback(() => {
236
+ if (livePlayheadRef) livePlayheadRef.current = playheadRef.current;
237
+ onPlayheadChange?.(playheadRef.current);
238
+ }, [livePlayheadRef, onPlayheadChange]);
198
239
 
199
240
  // Dock height (resizable via the top drag handle).
200
241
  const [dockHeight, setDockHeight] = useState(DEFAULT_DOCK_HEIGHT);
@@ -258,7 +299,9 @@ export function MotionDock({
258
299
  }
259
300
  playStartRef.current = null;
260
301
  setPlaying(false);
261
- }, []);
302
+ // Pause/stop is a commit point: hand the final position to the parent.
303
+ commitPlayhead();
304
+ }, [commitPlayhead]);
262
305
 
263
306
  const startPlayback = useCallback(() => {
264
307
  stopPlayback();
@@ -270,7 +313,7 @@ export function MotionDock({
270
313
  if (!playStartRef.current) return;
271
314
  const elapsed = now - playStartRef.current.wallMs;
272
315
  const t = Math.min(1, playStartRef.current.startT + elapsed / durationMs);
273
- setPlayheadValue(t);
316
+ setPlayheadLocal(t);
274
317
  sendPreview(t);
275
318
  if (t < 1) {
276
319
  playRafRef.current = requestAnimationFrame(tick);
@@ -279,7 +322,7 @@ export function MotionDock({
279
322
  }
280
323
  };
281
324
  playRafRef.current = requestAnimationFrame(tick);
282
- }, [durationMs, playhead, sendPreview, setPlayheadValue, stopPlayback]);
325
+ }, [durationMs, playhead, sendPreview, setPlayheadLocal, stopPlayback]);
283
326
 
284
327
  useEffect(() => {
285
328
  return () => {
@@ -298,10 +341,10 @@ export function MotionDock({
298
341
  (e.target as HTMLElement).setPointerCapture(e.pointerId);
299
342
  const rect = trackAreaRef.current.getBoundingClientRect();
300
343
  const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
301
- setPlayheadValue(t);
344
+ setPlayheadLocal(t);
302
345
  sendPreview(t);
303
346
  },
304
- [sendPreview, setPlayheadValue, stopPlayback],
347
+ [sendPreview, setPlayheadLocal, stopPlayback],
305
348
  );
306
349
 
307
350
  const handleRulerPointerMove = useCallback(
@@ -309,15 +352,18 @@ export function MotionDock({
309
352
  if (!isDraggingPlayhead.current || !trackAreaRef.current) return;
310
353
  const rect = trackAreaRef.current.getBoundingClientRect();
311
354
  const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
312
- setPlayheadValue(t);
355
+ setPlayheadLocal(t);
313
356
  sendPreview(t);
314
357
  },
315
- [sendPreview, setPlayheadValue],
358
+ [sendPreview, setPlayheadLocal],
316
359
  );
317
360
 
318
361
  const handleRulerPointerUp = useCallback(() => {
362
+ if (!isDraggingPlayhead.current) return;
319
363
  isDraggingPlayhead.current = false;
320
- }, []);
364
+ // Scrub end is a commit point.
365
+ commitPlayhead();
366
+ }, [commitPlayhead]);
321
367
 
322
368
  // ── Dock resize drag ─────────────────────────────────────────────────────
323
369
  const handleResizePointerDown = useCallback(
@@ -368,14 +414,25 @@ export function MotionDock({
368
414
 
369
415
  const addKeyframe = useCallback(
370
416
  (track: MotionDockTrack) => {
417
+ // Seed the new keyframe with the track's interpolated value at the
418
+ // playhead (what the preview currently shows) — a hardcoded "0" is
419
+ // invalid CSS for transform/filter/color tracks and snaps the preview.
420
+ const sampled = sampleMotionKeyframesAt(
421
+ track.keyframes,
422
+ playhead,
423
+ defaultEase,
424
+ );
371
425
  const newKf: MotionKeyframe = {
372
426
  t: playhead,
373
- value: "0",
427
+ value: sampled || "0",
374
428
  ease: defaultEase,
375
429
  };
376
430
  updateTrack(track.targetNodeId, track.property, (tr) => ({
377
431
  ...tr,
378
- keyframes: [...tr.keyframes, newKf].sort((a, b) => a.t - b.t),
432
+ // Epsilon replace-at-time: adding at (nearly) the same playhead
433
+ // position replaces the existing stop instead of stacking an
434
+ // invisible duplicate diamond.
435
+ keyframes: upsertMotionKeyframeAtTime(tr.keyframes, newKf),
379
436
  }));
380
437
  },
381
438
  [defaultEase, playhead, updateTrack],
@@ -400,7 +457,13 @@ export function MotionDock({
400
457
  });
401
458
 
402
459
  if (hasTrackFor(tracks, nodeId, preset.property)) {
403
- // Track already exists — do not duplicate; the expand above surfaces it.
460
+ // Track already exists — do not duplicate; the expand above surfaces
461
+ // it, and an explicit notice explains why nothing new appeared (two
462
+ // presets can target the same property, e.g. slide + scale are both
463
+ // "transform").
464
+ toast.info(
465
+ `A "${preset.property}" track already exists for ${label}. Edit its keyframes in the timeline instead.`,
466
+ );
404
467
  return;
405
468
  }
406
469
 
@@ -412,10 +475,63 @@ export function MotionDock({
412
475
  );
413
476
 
414
477
  const deleteKeyframe = useCallback(
415
- (track: MotionDockTrack, kf: MotionKeyframe) => {
478
+ (track: MotionDockTrack, index: number) => {
479
+ if (!onTracksChange) return;
480
+ if (track.keyframes.length <= 1) {
481
+ // Deleting the last keyframe removes the whole track: a 0-keyframe
482
+ // track cannot compile and is rejected by apply-motion-edit, which
483
+ // would brick every subsequent autosave with no UI to recover.
484
+ onTracksChange(
485
+ tracks.filter(
486
+ (tr) =>
487
+ !(
488
+ tr.targetNodeId === track.targetNodeId &&
489
+ tr.property === track.property
490
+ ),
491
+ ),
492
+ );
493
+ return;
494
+ }
495
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
496
+ ...tr,
497
+ keyframes: tr.keyframes.filter((_, i) => i !== index),
498
+ }));
499
+ },
500
+ [onTracksChange, tracks, updateTrack],
501
+ );
502
+
503
+ const moveKeyframe = useCallback(
504
+ (track: MotionDockTrack, index: number, newT: number) => {
416
505
  updateTrack(track.targetNodeId, track.property, (tr) => ({
417
506
  ...tr,
418
- keyframes: tr.keyframes.filter((k) => k !== kf),
507
+ keyframes: tr.keyframes.map((kf, i) =>
508
+ i === index ? { ...kf, t: newT } : kf,
509
+ ),
510
+ }));
511
+ },
512
+ [updateTrack],
513
+ );
514
+
515
+ // Re-sort a track's keyframes once a drag finishes. Sorting DURING the drag
516
+ // would reshuffle the dragged keyframe's index mid-gesture; the preview
517
+ // bridge and compiler sort defensively, so drag-time order is safe.
518
+ const moveKeyframeEnd = useCallback(
519
+ (track: MotionDockTrack) => {
520
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
521
+ ...tr,
522
+ keyframes: sortMotionKeyframes(tr.keyframes),
523
+ }));
524
+ },
525
+ [updateTrack],
526
+ );
527
+
528
+ const easeKeyframe = useCallback(
529
+ (track: MotionDockTrack, index: number, ease: MotionEase) => {
530
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
531
+ ...tr,
532
+ keyframes: tr.keyframes.map((kf, i) =>
533
+ i === index ? { ...kf, ease } : kf,
534
+ ),
419
535
  }));
420
536
  },
421
537
  [updateTrack],
@@ -528,7 +644,8 @@ export function MotionDock({
528
644
  className="size-6 shrink-0"
529
645
  onClick={() => {
530
646
  stopPlayback();
531
- setPlayheadValue(0);
647
+ setPlayheadLocal(0);
648
+ commitPlayhead();
532
649
  sendPreview(0);
533
650
  }}
534
651
  aria-label="Reset playhead"
@@ -708,14 +825,9 @@ export function MotionDock({
708
825
  expanded={expandedNodeIds.has(layer.nodeId)}
709
826
  playhead={playhead}
710
827
  onDeleteKeyframe={deleteKeyframe}
711
- onMoveKeyframe={(track, kf, newT) => {
712
- updateTrack(track.targetNodeId, track.property, (tr) => ({
713
- ...tr,
714
- keyframes: tr.keyframes.map((k) =>
715
- k === kf ? { ...k, t: newT } : k,
716
- ),
717
- }));
718
- }}
828
+ onMoveKeyframe={moveKeyframe}
829
+ onMoveKeyframeEnd={moveKeyframeEnd}
830
+ onEaseKeyframe={easeKeyframe}
719
831
  />
720
832
  ))}
721
833
 
@@ -933,11 +1045,13 @@ interface LayerTrackRowsProps {
933
1045
  layer: { nodeId: string; label: string; tracks: MotionDockTrack[] };
934
1046
  expanded: boolean;
935
1047
  playhead: number;
936
- onDeleteKeyframe: (track: MotionDockTrack, kf: MotionKeyframe) => void;
937
- onMoveKeyframe: (
1048
+ onDeleteKeyframe: (track: MotionDockTrack, index: number) => void;
1049
+ onMoveKeyframe: (track: MotionDockTrack, index: number, newT: number) => void;
1050
+ onMoveKeyframeEnd: (track: MotionDockTrack) => void;
1051
+ onEaseKeyframe: (
938
1052
  track: MotionDockTrack,
939
- kf: MotionKeyframe,
940
- newT: number,
1053
+ index: number,
1054
+ ease: MotionEase,
941
1055
  ) => void;
942
1056
  }
943
1057
 
@@ -947,6 +1061,8 @@ function LayerTrackRows({
947
1061
  playhead: _playhead,
948
1062
  onDeleteKeyframe,
949
1063
  onMoveKeyframe,
1064
+ onMoveKeyframeEnd,
1065
+ onEaseKeyframe,
950
1066
  }: LayerTrackRowsProps) {
951
1067
  return (
952
1068
  <>
@@ -962,8 +1078,10 @@ function LayerTrackRows({
962
1078
  <TrackRow
963
1079
  key={`${track.targetNodeId}-${track.property}`}
964
1080
  track={track}
965
- onDeleteKeyframe={(kf) => onDeleteKeyframe(track, kf)}
966
- onMoveKeyframe={(kf, newT) => onMoveKeyframe(track, kf, newT)}
1081
+ onDeleteKeyframe={(index) => onDeleteKeyframe(track, index)}
1082
+ onMoveKeyframe={(index, newT) => onMoveKeyframe(track, index, newT)}
1083
+ onMoveKeyframeEnd={() => onMoveKeyframeEnd(track)}
1084
+ onEaseKeyframe={(index, ease) => onEaseKeyframe(track, index, ease)}
967
1085
  />
968
1086
  ))}
969
1087
  </>
@@ -972,22 +1090,44 @@ function LayerTrackRows({
972
1090
 
973
1091
  interface TrackRowProps {
974
1092
  track: MotionDockTrack;
975
- onDeleteKeyframe: (kf: MotionKeyframe) => void;
976
- onMoveKeyframe: (kf: MotionKeyframe, newT: number) => void;
1093
+ onDeleteKeyframe: (index: number) => void;
1094
+ onMoveKeyframe: (index: number, newT: number) => void;
1095
+ onMoveKeyframeEnd: () => void;
1096
+ onEaseKeyframe: (index: number, ease: MotionEase) => void;
977
1097
  }
978
1098
 
979
- function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
1099
+ function TrackRow({
1100
+ track,
1101
+ onDeleteKeyframe,
1102
+ onMoveKeyframe,
1103
+ onMoveKeyframeEnd,
1104
+ onEaseKeyframe,
1105
+ }: TrackRowProps) {
980
1106
  const rowRef = useRef<HTMLDivElement>(null);
1107
+ // Identify the dragged keyframe by its INDEX in track.keyframes, not object
1108
+ // identity: the parent replaces keyframe objects on every move (immutable
1109
+ // update), so a captured object reference goes stale after the first move
1110
+ // and every subsequent pointermove would match nothing (frozen keyframe).
981
1111
  const dragging = useRef<{
982
- kf: MotionKeyframe;
1112
+ index: number;
983
1113
  startX: number;
984
1114
  startT: number;
1115
+ moved: boolean;
985
1116
  } | null>(null);
986
1117
 
987
1118
  const handleDiamondPointerDown = useCallback(
988
- (e: ReactPointerEvent<SVGSVGElement>, kf: MotionKeyframe) => {
1119
+ (
1120
+ e: ReactPointerEvent<SVGSVGElement>,
1121
+ index: number,
1122
+ kf: MotionKeyframe,
1123
+ ) => {
989
1124
  e.stopPropagation();
990
- dragging.current = { kf, startX: e.clientX, startT: kf.t };
1125
+ dragging.current = {
1126
+ index,
1127
+ startX: e.clientX,
1128
+ startT: kf.t,
1129
+ moved: false,
1130
+ };
991
1131
  (e.target as Element).setPointerCapture(e.pointerId);
992
1132
  },
993
1133
  [],
@@ -1000,14 +1140,19 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
1000
1140
  const dx = e.clientX - dragging.current.startX;
1001
1141
  const dt = dx / rect.width;
1002
1142
  const newT = Math.max(0, Math.min(1, dragging.current.startT + dt));
1003
- onMoveKeyframe(dragging.current.kf, newT);
1143
+ dragging.current.moved = true;
1144
+ onMoveKeyframe(dragging.current.index, newT);
1004
1145
  },
1005
1146
  [onMoveKeyframe],
1006
1147
  );
1007
1148
 
1008
1149
  const handlePointerUp = useCallback(() => {
1150
+ const wasMoved = dragging.current?.moved === true;
1009
1151
  dragging.current = null;
1010
- }, []);
1152
+ // Only re-sort (and re-mark dirty) when the keyframe actually moved — a
1153
+ // plain click on a diamond must not trigger an autosave.
1154
+ if (wasMoved) onMoveKeyframeEnd();
1155
+ }, [onMoveKeyframeEnd]);
1011
1156
 
1012
1157
  return (
1013
1158
  <div
@@ -1016,6 +1161,7 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
1016
1161
  style={{ height: ROW_HEIGHT }}
1017
1162
  onPointerMove={handlePointerMove}
1018
1163
  onPointerUp={handlePointerUp}
1164
+ onPointerCancel={handlePointerUp}
1019
1165
  >
1020
1166
  {/* Track baseline */}
1021
1167
  <div className="absolute inset-y-1/2 left-0 right-0 h-px bg-border/50" />
@@ -1025,8 +1171,9 @@ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
1025
1171
  <KeyframeDiamond
1026
1172
  key={i}
1027
1173
  kf={kf}
1028
- onPointerDown={(e) => handleDiamondPointerDown(e, kf)}
1029
- onDelete={() => onDeleteKeyframe(kf)}
1174
+ onPointerDown={(e) => handleDiamondPointerDown(e, i, kf)}
1175
+ onDelete={() => onDeleteKeyframe(i)}
1176
+ onEaseChange={(ease) => onEaseKeyframe(i, ease)}
1030
1177
  />
1031
1178
  ))}
1032
1179
  </div>
@@ -1037,14 +1184,20 @@ interface KeyframeDiamondProps {
1037
1184
  kf: MotionKeyframe;
1038
1185
  onPointerDown: (e: ReactPointerEvent<SVGSVGElement>) => void;
1039
1186
  onDelete: () => void;
1187
+ onEaseChange: (ease: MotionEase) => void;
1040
1188
  }
1041
1189
 
1042
1190
  function KeyframeDiamond({
1043
1191
  kf,
1044
1192
  onPointerDown,
1045
1193
  onDelete,
1194
+ onEaseChange,
1046
1195
  }: KeyframeDiamondProps) {
1047
1196
  const [hovered, setHovered] = useState(false);
1197
+ const currentEase = kf.ease ?? "ease";
1198
+ const currentPreset = EASE_PRESETS.find(
1199
+ (preset) => preset.value === currentEase,
1200
+ );
1048
1201
 
1049
1202
  return (
1050
1203
  <div
@@ -1085,7 +1238,8 @@ function KeyframeDiamond({
1085
1238
  className="flex items-center gap-1 text-[10px]"
1086
1239
  >
1087
1240
  <span>
1088
- {Math.round(kf.t * 100)}% — {kf.value}
1241
+ {Math.round(kf.t * 100)}% — {kf.value} —{" "}
1242
+ {currentPreset?.label ?? currentEase}
1089
1243
  </span>
1090
1244
  <button
1091
1245
  type="button"
@@ -1097,6 +1251,40 @@ function KeyframeDiamond({
1097
1251
  </button>
1098
1252
  </TooltipContent>
1099
1253
  </Tooltip>
1254
+
1255
+ {/* Easing picker — appears under the diamond on hover. */}
1256
+ <DropdownMenu>
1257
+ <DropdownMenuTrigger asChild>
1258
+ <button
1259
+ type="button"
1260
+ className="absolute left-1/2 top-full flex size-3 -translate-x-1/2 cursor-pointer items-center justify-center rounded-sm text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[state=open]:opacity-100"
1261
+ aria-label={`Change easing (currently ${currentPreset?.label ?? currentEase})`}
1262
+ >
1263
+ <IconChevronDown className="size-2.5" />
1264
+ </button>
1265
+ </DropdownMenuTrigger>
1266
+ <DropdownMenuContent align="start" className="w-40 p-1">
1267
+ <DropdownMenuLabel className="px-2 py-1 text-[10px] font-medium leading-none text-muted-foreground">
1268
+ Easing
1269
+ </DropdownMenuLabel>
1270
+ <DropdownMenuSeparator className="my-1" />
1271
+ {EASE_PRESETS.map((preset) => (
1272
+ <DropdownMenuItem
1273
+ key={preset.value}
1274
+ className="h-7 px-2 text-[12px] leading-none"
1275
+ onSelect={() => onEaseChange(preset.value)}
1276
+ >
1277
+ <IconCheck
1278
+ className={cn(
1279
+ "mr-1 size-3",
1280
+ preset.value === currentEase ? "opacity-100" : "opacity-0",
1281
+ )}
1282
+ />
1283
+ {preset.label}
1284
+ </DropdownMenuItem>
1285
+ ))}
1286
+ </DropdownMenuContent>
1287
+ </DropdownMenu>
1100
1288
  </div>
1101
1289
  );
1102
1290
  }