@agent-native/core 0.80.9 → 0.80.11

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 (148) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +13 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/cli/design-connect.ts +28 -2
  6. package/corpus/core/src/cli/skills.ts +45 -24
  7. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  8. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  9. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  10. package/corpus/templates/assets/AGENTS.md +4 -0
  11. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  12. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  13. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  14. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  15. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  16. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  17. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  18. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  19. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  20. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  21. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  22. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  23. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  24. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  25. package/corpus/templates/assets/shared/api.ts +4 -0
  26. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  27. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  28. package/corpus/templates/design/DESIGN-STUDIO-PLAN.md +717 -0
  29. package/corpus/templates/design/actions/add-breakpoint.ts +143 -0
  30. package/corpus/templates/design/actions/add-localhost-screens.ts +5 -0
  31. package/corpus/templates/design/actions/apply-a11y-fix.ts +317 -0
  32. package/corpus/templates/design/actions/apply-component-prop-edit.ts +441 -0
  33. package/corpus/templates/design/actions/apply-design-state.ts +213 -0
  34. package/corpus/templates/design/actions/apply-design-token-edit.ts +202 -0
  35. package/corpus/templates/design/actions/apply-motion-edit.ts +413 -0
  36. package/corpus/templates/design/actions/apply-shader-fill.ts +404 -0
  37. package/corpus/templates/design/actions/apply-visual-edit.ts +191 -5
  38. package/corpus/templates/design/actions/capture-design-state.ts +224 -0
  39. package/corpus/templates/design/actions/connect-builder-app.ts +162 -0
  40. package/corpus/templates/design/actions/create-component.ts +447 -0
  41. package/corpus/templates/design/actions/create-design-branch.ts +263 -0
  42. package/corpus/templates/design/actions/create-design-state.ts +162 -0
  43. package/corpus/templates/design/actions/delete-design-state.ts +55 -0
  44. package/corpus/templates/design/actions/delete-design.ts +20 -0
  45. package/corpus/templates/design/actions/deploy-design-preview.ts +275 -0
  46. package/corpus/templates/design/actions/get-component-details.ts +251 -0
  47. package/corpus/templates/design/actions/get-design-branch-diff.ts +362 -0
  48. package/corpus/templates/design/actions/get-design-review.ts +314 -0
  49. package/corpus/templates/design/actions/get-design-surface-index.ts +582 -0
  50. package/corpus/templates/design/actions/get-motion-timeline.ts +99 -0
  51. package/corpus/templates/design/actions/index-components.ts +258 -0
  52. package/corpus/templates/design/actions/index-design-tokens.ts +277 -0
  53. package/corpus/templates/design/actions/list-design-extensions.ts +309 -0
  54. package/corpus/templates/design/actions/list-design-source-capabilities.ts +153 -0
  55. package/corpus/templates/design/actions/list-design-states.ts +90 -0
  56. package/corpus/templates/design/actions/migrate-inline-design-to-app.ts +298 -0
  57. package/corpus/templates/design/actions/open-component-source.ts +242 -0
  58. package/corpus/templates/design/actions/preview-component-prop-edit.ts +260 -0
  59. package/corpus/templates/design/actions/preview-design-token-edit.ts +113 -0
  60. package/corpus/templates/design/actions/preview-shader-fill.ts +187 -0
  61. package/corpus/templates/design/actions/remove-breakpoint.ts +103 -0
  62. package/corpus/templates/design/actions/remove-motion-timeline.ts +196 -0
  63. package/corpus/templates/design/actions/run-design-audit.ts +436 -0
  64. package/corpus/templates/design/actions/run-design-extension-action.ts +284 -0
  65. package/corpus/templates/design/actions/set-active-breakpoint.ts +39 -0
  66. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +1550 -82
  67. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +649 -43
  68. package/corpus/templates/design/app/components/design/DrawOverlay.tsx +1 -1
  69. package/corpus/templates/design/app/components/design/EditPanel.tsx +1788 -52
  70. package/corpus/templates/design/app/components/design/LayersPanel.tsx +114 -33
  71. package/corpus/templates/design/app/components/design/LocalSourceEditBanner.tsx +157 -0
  72. package/corpus/templates/design/app/components/design/MotionDock.tsx +1071 -0
  73. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1053 -99
  74. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +766 -0
  75. package/corpus/templates/design/app/components/design/StatesPanel.tsx +581 -0
  76. package/corpus/templates/design/app/components/design/TokensPanel.tsx +578 -0
  77. package/corpus/templates/design/app/components/design/canvas-primitive-style.ts +241 -0
  78. package/corpus/templates/design/app/components/design/index.ts +16 -0
  79. package/corpus/templates/design/app/components/design/inspector/AutoLayoutMatrix.tsx +15 -6
  80. package/corpus/templates/design/app/components/design/inspector/DesignColorPicker.tsx +66 -3
  81. package/corpus/templates/design/app/components/design/inspector/InspectorAiActions.tsx +145 -0
  82. package/corpus/templates/design/app/components/design/inspector/SHADER_INTEGRATION.md +42 -12
  83. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +1 -1
  84. package/corpus/templates/design/app/hooks/useAgentEditRequest.ts +131 -0
  85. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +3 -1
  86. package/corpus/templates/design/app/i18n/zh-TW.ts +32 -0
  87. package/corpus/templates/design/app/i18n-data.ts +280 -0
  88. package/corpus/templates/design/app/pages/DesignEditor.tsx +3087 -490
  89. package/corpus/templates/design/changelog/2026-06-29-added-a-review-panel-in-the-design-editor-s-inspector-with-a.md +6 -0
  90. package/corpus/templates/design/changelog/2026-06-29-copying-and-pasting-layers-no-longer-shows-success-notificat.md +6 -0
  91. package/corpus/templates/design/changelog/2026-06-29-device-presets-in-all-screens-view-resize-the-selected-previ.md +6 -0
  92. package/corpus/templates/design/changelog/2026-06-29-layer-and-canvas-drags-keep-the-layer-list-stable-while-chan.md +6 -0
  93. package/corpus/templates/design/changelog/2026-06-29-layer-moves-can-be-undone-and-redone-without-flashing-the-ca.md +6 -0
  94. package/corpus/templates/design/changelog/2026-06-29-screen-previews-use-a-single-blue-hover-border-in-all-screen.md +6 -0
  95. package/corpus/templates/design/changelog/2026-06-29-selected-containers-show-draggable-padding-and-gap-guides-on.md +6 -0
  96. package/corpus/templates/design/changelog/2026-06-29-the-design-editor-adds-a-studio-layer-with-tokens-respon.md +6 -0
  97. package/corpus/templates/design/changelog/2026-06-30-accessibility-findings-now-offer-a-one-click-fix.md +6 -0
  98. package/corpus/templates/design/changelog/2026-06-30-component-instances-now-have-editable-props-in-the-inspe.md +6 -0
  99. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  100. package/corpus/templates/design/changelog/2026-06-30-inspect-code-now-shows-the-elements-opening-tag-at-a-gla.md +6 -0
  101. package/corpus/templates/design/changelog/2026-06-30-shader-fill-presets-can-now-be-applied-to-an-element.md +6 -0
  102. package/corpus/templates/design/changelog/2026-06-30-the-motion-timeline-can-now-add-a-track-to-any-element-a.md +6 -0
  103. package/corpus/templates/design/changelog/2026-06-30-visual-editor-selection-layer-paint-and-drawing-controls-are-more-reliable.md +6 -0
  104. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  105. package/corpus/templates/design/e2e/helpers.ts +10 -4
  106. package/corpus/templates/design/server/db/schema.ts +123 -0
  107. package/corpus/templates/design/server/plugins/db.ts +90 -0
  108. package/corpus/templates/design/shared/builder-app.ts +297 -0
  109. package/corpus/templates/design/shared/capability-resolver.ts +123 -0
  110. package/corpus/templates/design/shared/capture-sanitize.ts +70 -0
  111. package/corpus/templates/design/shared/code-layer.ts +1016 -71
  112. package/corpus/templates/design/shared/component-model.ts +274 -0
  113. package/corpus/templates/design/shared/design-review.ts +275 -0
  114. package/corpus/templates/design/shared/design-source-capabilities.ts +286 -0
  115. package/corpus/templates/design/shared/design-state.ts +112 -0
  116. package/corpus/templates/design/shared/design-surface-index.ts +258 -0
  117. package/corpus/templates/design/shared/motion-compiler.ts +349 -0
  118. package/corpus/templates/design/shared/motion-timeline.ts +193 -0
  119. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  120. package/corpus/templates/design/shared/responsive-classes.ts +452 -0
  121. package/corpus/templates/design/shared/shader-fill.ts +323 -0
  122. package/corpus/templates/design/shared/source-mode.ts +245 -0
  123. package/corpus/templates/plan/app/components/plan/CanvasArea.tsx +6 -2
  124. package/corpus/templates/plan/changelog/2026-06-29-plan-canvases-open-without-an-initial-pan-and-zoom-flicker.md +6 -0
  125. package/dist/agent/production-agent.d.ts.map +1 -1
  126. package/dist/agent/production-agent.js +94 -10
  127. package/dist/agent/production-agent.js.map +1 -1
  128. package/dist/cli/design-connect.d.ts.map +1 -1
  129. package/dist/cli/design-connect.js +28 -2
  130. package/dist/cli/design-connect.js.map +1 -1
  131. package/dist/cli/skills.d.ts.map +1 -1
  132. package/dist/cli/skills.js +37 -21
  133. package/dist/cli/skills.js.map +1 -1
  134. package/dist/client/AssistantChat.d.ts +14 -0
  135. package/dist/client/AssistantChat.d.ts.map +1 -1
  136. package/dist/client/AssistantChat.js +115 -22
  137. package/dist/client/AssistantChat.js.map +1 -1
  138. package/dist/client/sse-event-processor.d.ts.map +1 -1
  139. package/dist/client/sse-event-processor.js +10 -0
  140. package/dist/client/sse-event-processor.js.map +1 -1
  141. package/dist/collab/routes.d.ts +1 -1
  142. package/dist/file-upload/actions/upload-image.d.ts +2 -2
  143. package/dist/notifications/routes.d.ts +2 -2
  144. package/dist/observability/routes.d.ts +8 -8
  145. package/dist/resources/handlers.d.ts +2 -2
  146. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  147. package/dist/server/transcribe-voice.d.ts +1 -1
  148. package/package.json +1 -1
@@ -0,0 +1,1071 @@
1
+ // i18n-raw-literal-disable-file — new Design Studio panel; UI strings are localized when this feature is finalized in the follow-up PR.
2
+ /**
3
+ * MotionDock — bottom motion timeline dock for the Design Studio (§6.3).
4
+ *
5
+ * Matches the motion artboard at
6
+ * https://plan.agent-native.com/plans/plan-88dc4a09fb0c46bc:
7
+ * - Full-width collapsible dock beneath the canvas.
8
+ * - Left sidebar: animated layer rows with property sub-rows.
9
+ * - Center: time ruler + diamond keyframes on a track grid.
10
+ * - Playhead: draggable; scrubbing sends a preview-only `motion-preview`
11
+ * postMessage to the canvas iframe — NEVER writes to DB.
12
+ * - Top toolbar: play/pause, duration input, auto-keyframe toggle, Write to CSS.
13
+ *
14
+ * Write to CSS calls the parent's `onApply` prop, which should invoke
15
+ * `apply-motion-edit` through `useActionMutation`.
16
+ *
17
+ * All times are normalised to [0, 1] internally; the ruler maps them to px.
18
+ */
19
+
20
+ import type {
21
+ MotionTrack,
22
+ MotionKeyframe,
23
+ MotionEase,
24
+ MotionPropertyPreset,
25
+ } from "@shared/motion-timeline";
26
+ import {
27
+ MOTION_PROPERTY_PRESETS,
28
+ createMotionTrackFromPreset,
29
+ hasTrackFor,
30
+ } from "@shared/motion-timeline";
31
+ import {
32
+ IconPlayerPlay,
33
+ IconPlayerPause,
34
+ IconPlayerStop,
35
+ IconDiamond,
36
+ IconChevronDown,
37
+ IconChevronRight,
38
+ IconPlus,
39
+ IconTrash,
40
+ IconCode,
41
+ IconRefresh,
42
+ IconBolt,
43
+ IconLayersSubtract,
44
+ } from "@tabler/icons-react";
45
+ import {
46
+ useCallback,
47
+ useEffect,
48
+ useRef,
49
+ useState,
50
+ type PointerEvent as ReactPointerEvent,
51
+ } from "react";
52
+
53
+ import { Button } from "@/components/ui/button";
54
+ import {
55
+ DropdownMenu,
56
+ DropdownMenuContent,
57
+ DropdownMenuItem,
58
+ DropdownMenuLabel,
59
+ DropdownMenuSeparator,
60
+ DropdownMenuTrigger,
61
+ } from "@/components/ui/dropdown-menu";
62
+ import { Input } from "@/components/ui/input";
63
+ import {
64
+ Tooltip,
65
+ TooltipContent,
66
+ TooltipTrigger,
67
+ } from "@/components/ui/tooltip";
68
+ import { cn } from "@/lib/utils";
69
+
70
+ // ─── Constants ────────────────────────────────────────────────────────────────
71
+
72
+ const RULER_HEIGHT = 24; // px
73
+ const ROW_HEIGHT = 32; // px
74
+ const LAYER_SIDEBAR_WIDTH = 200; // px
75
+ const PLAYHEAD_WIDTH = 2; // px
76
+ const MIN_DOCK_HEIGHT = 160; // px
77
+ const DEFAULT_DOCK_HEIGHT = 280; // px
78
+
79
+ /** Named easing presets for the keyframe editor. */
80
+ const EASE_PRESETS: { label: string; value: MotionEase }[] = [
81
+ { label: "Linear", value: "linear" },
82
+ { label: "Ease", value: "ease" },
83
+ { label: "Ease In", value: "ease-in" },
84
+ { label: "Ease Out", value: "ease-out" },
85
+ { label: "Ease In/Out", value: "ease-in-out" },
86
+ { label: "Spring", value: "cubic-bezier(0.34,1.56,0.64,1)" },
87
+ ];
88
+
89
+ // ─── Types ────────────────────────────────────────────────────────────────────
90
+
91
+ export interface MotionDockTrack extends MotionTrack {
92
+ /** Human-readable label derived from data-agent-native-layer-name or nodeId. */
93
+ label: string;
94
+ }
95
+
96
+ export interface MotionDockProps {
97
+ /** Tracks to display. Each track maps to one layer row. */
98
+ tracks: MotionDockTrack[];
99
+ /** Total animation duration in milliseconds. */
100
+ durationMs: number;
101
+ /** Default easing applied to keyframes that omit ease. */
102
+ defaultEase?: MotionEase;
103
+ /** Controlled open state. */
104
+ open?: boolean;
105
+ /** Called when the user toggles the dock open/closed. */
106
+ onOpenChange?: (open: boolean) => void;
107
+ /** Called when a track is modified (add/move/delete keyframe or change value). */
108
+ onTracksChange?: (tracks: MotionDockTrack[]) => void;
109
+ /** Called when durationMs is edited. */
110
+ onDurationChange?: (ms: number) => void;
111
+ /**
112
+ * Called when "Write to CSS" is clicked. The parent should call
113
+ * `apply-motion-edit` via useActionMutation.
114
+ */
115
+ onApply?: (tracks: MotionDockTrack[], durationMs: number) => void;
116
+ /**
117
+ * Reference to the canvas iframe element. Used to send preview postMessages.
118
+ * If not provided, preview messages are skipped (no crash).
119
+ */
120
+ canvasIframeRef?: React.RefObject<HTMLIFrameElement | null>;
121
+ /** Whether the parent apply mutation is in flight. */
122
+ applying?: boolean;
123
+ /**
124
+ * The currently-selected canvas element, if any. Required to create the FIRST
125
+ * track for a layer: the picker animates this node's
126
+ * `data-agent-native-node-id`. `null` when nothing is selected — the create
127
+ * affordance is then disabled with a hint to select an element.
128
+ */
129
+ selectedTarget?: { nodeId: string; label: string } | null;
130
+ }
131
+
132
+ // ─── Component ────────────────────────────────────────────────────────────────
133
+
134
+ export function MotionDock({
135
+ tracks,
136
+ durationMs,
137
+ defaultEase = "ease",
138
+ open: openProp,
139
+ onOpenChange,
140
+ onTracksChange,
141
+ onDurationChange,
142
+ onApply,
143
+ canvasIframeRef,
144
+ applying = false,
145
+ selectedTarget = null,
146
+ }: MotionDockProps) {
147
+ // Controlled / uncontrolled open state.
148
+ const [openInternal, setOpenInternal] = useState(false);
149
+ const isOpen = openProp !== undefined ? openProp : openInternal;
150
+ const setOpen = useCallback(
151
+ (v: boolean) => {
152
+ setOpenInternal(v);
153
+ onOpenChange?.(v);
154
+ },
155
+ [onOpenChange],
156
+ );
157
+
158
+ // Playhead position: normalised [0, 1].
159
+ const [playhead, setPlayhead] = useState(0);
160
+ const [playing, setPlaying] = useState(false);
161
+ const playRafRef = useRef<number | null>(null);
162
+ const playStartRef = useRef<{ wallMs: number; startT: number } | null>(null);
163
+
164
+ // Auto-keyframe mode: clicking the canvas at a time scrubs without writing.
165
+ const [autoKeyframe, setAutoKeyframe] = useState(false);
166
+
167
+ // Dock height (resizable via the top drag handle).
168
+ const [dockHeight, setDockHeight] = useState(DEFAULT_DOCK_HEIGHT);
169
+ const resizingRef = useRef(false);
170
+ const resizeStartRef = useRef<{ y: number; h: number } | null>(null);
171
+
172
+ // Expanded layers in the sidebar.
173
+ const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(
174
+ () => new Set(tracks.map((t) => t.targetNodeId)),
175
+ );
176
+
177
+ useEffect(() => {
178
+ setExpandedNodeIds((current) => {
179
+ const next = new Set(current);
180
+ for (const track of tracks) next.add(track.targetNodeId);
181
+ return next.size === current.size ? current : next;
182
+ });
183
+ }, [tracks]);
184
+
185
+ // Ruler / track area ref for pointer math.
186
+ const trackAreaRef = useRef<HTMLDivElement>(null);
187
+
188
+ // Local duration input state.
189
+ const [durationInput, setDurationInput] = useState(String(durationMs));
190
+ useEffect(() => {
191
+ setDurationInput(String(durationMs));
192
+ }, [durationMs]);
193
+
194
+ // ── Preview postMessage ──────────────────────────────────────────────────
195
+ const sendPreview = useCallback(
196
+ (t: number) => {
197
+ const iframe = canvasIframeRef?.current;
198
+ if (!iframe?.contentWindow) return;
199
+ try {
200
+ iframe.contentWindow.postMessage(
201
+ { type: "motion-preview", t, durationMs },
202
+ "*",
203
+ );
204
+ } catch {
205
+ // Best-effort preview; never throw.
206
+ }
207
+ },
208
+ [canvasIframeRef, durationMs],
209
+ );
210
+
211
+ // ── Playback ─────────────────────────────────────────────────────────────
212
+ const stopPlayback = useCallback(() => {
213
+ if (playRafRef.current !== null) {
214
+ cancelAnimationFrame(playRafRef.current);
215
+ playRafRef.current = null;
216
+ }
217
+ playStartRef.current = null;
218
+ setPlaying(false);
219
+ }, []);
220
+
221
+ const startPlayback = useCallback(() => {
222
+ stopPlayback();
223
+ const startT = playhead >= 1 ? 0 : playhead;
224
+ playStartRef.current = { wallMs: performance.now(), startT };
225
+ setPlaying(true);
226
+
227
+ const tick = (now: number) => {
228
+ if (!playStartRef.current) return;
229
+ const elapsed = now - playStartRef.current.wallMs;
230
+ const t = Math.min(1, playStartRef.current.startT + elapsed / durationMs);
231
+ setPlayhead(t);
232
+ sendPreview(t);
233
+ if (t < 1) {
234
+ playRafRef.current = requestAnimationFrame(tick);
235
+ } else {
236
+ stopPlayback();
237
+ }
238
+ };
239
+ playRafRef.current = requestAnimationFrame(tick);
240
+ }, [durationMs, playhead, sendPreview, stopPlayback]);
241
+
242
+ useEffect(() => {
243
+ return () => {
244
+ if (playRafRef.current !== null) cancelAnimationFrame(playRafRef.current);
245
+ };
246
+ }, []);
247
+
248
+ // ── Playhead drag ─────────────────────────────────────────────────────────
249
+ const isDraggingPlayhead = useRef(false);
250
+
251
+ const handleRulerPointerDown = useCallback(
252
+ (e: ReactPointerEvent<HTMLDivElement>) => {
253
+ if (!trackAreaRef.current) return;
254
+ isDraggingPlayhead.current = true;
255
+ stopPlayback();
256
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
257
+ const rect = trackAreaRef.current.getBoundingClientRect();
258
+ const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
259
+ setPlayhead(t);
260
+ sendPreview(t);
261
+ },
262
+ [sendPreview, stopPlayback],
263
+ );
264
+
265
+ const handleRulerPointerMove = useCallback(
266
+ (e: ReactPointerEvent<HTMLDivElement>) => {
267
+ if (!isDraggingPlayhead.current || !trackAreaRef.current) return;
268
+ const rect = trackAreaRef.current.getBoundingClientRect();
269
+ const t = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
270
+ setPlayhead(t);
271
+ sendPreview(t);
272
+ },
273
+ [sendPreview],
274
+ );
275
+
276
+ const handleRulerPointerUp = useCallback(() => {
277
+ isDraggingPlayhead.current = false;
278
+ }, []);
279
+
280
+ // ── Dock resize drag ─────────────────────────────────────────────────────
281
+ const handleResizePointerDown = useCallback(
282
+ (e: ReactPointerEvent<HTMLDivElement>) => {
283
+ resizingRef.current = true;
284
+ resizeStartRef.current = { y: e.clientY, h: dockHeight };
285
+ (e.target as HTMLElement).setPointerCapture(e.pointerId);
286
+ },
287
+ [dockHeight],
288
+ );
289
+
290
+ const handleResizePointerMove = useCallback(
291
+ (e: ReactPointerEvent<HTMLDivElement>) => {
292
+ if (!resizingRef.current || !resizeStartRef.current) return;
293
+ const delta = resizeStartRef.current.y - e.clientY;
294
+ setDockHeight(
295
+ Math.max(MIN_DOCK_HEIGHT, resizeStartRef.current.h + delta),
296
+ );
297
+ },
298
+ [],
299
+ );
300
+
301
+ const handleResizePointerUp = useCallback(() => {
302
+ resizingRef.current = false;
303
+ resizeStartRef.current = null;
304
+ }, []);
305
+
306
+ // ── Keyframe helpers ─────────────────────────────────────────────────────
307
+ const updateTrack = useCallback(
308
+ (
309
+ nodeId: string,
310
+ property: string,
311
+ updater: (track: MotionDockTrack) => MotionDockTrack,
312
+ ) => {
313
+ if (!onTracksChange) return;
314
+ onTracksChange(
315
+ tracks.map((tr) =>
316
+ tr.targetNodeId === nodeId && tr.property === property
317
+ ? updater(tr)
318
+ : tr,
319
+ ),
320
+ );
321
+ },
322
+ [tracks, onTracksChange],
323
+ );
324
+
325
+ const addKeyframe = useCallback(
326
+ (track: MotionDockTrack) => {
327
+ const newKf: MotionKeyframe = {
328
+ t: playhead,
329
+ value: "0",
330
+ ease: defaultEase,
331
+ };
332
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
333
+ ...tr,
334
+ keyframes: [...tr.keyframes, newKf].sort((a, b) => a.t - b.t),
335
+ }));
336
+ },
337
+ [defaultEase, playhead, updateTrack],
338
+ );
339
+
340
+ // ── Create a brand-new track (the "first track" path) ──────────────────────
341
+ // This is the entry point that turns the dock from a dead end into a working
342
+ // editor: with an element selected and no track yet, the user picks a property
343
+ // preset and we seed a two-keyframe track. Once at least one track exists,
344
+ // "Write to CSS" enables. Idempotent per (nodeId, property) — picking the same
345
+ // property twice just re-expands the existing track instead of duplicating.
346
+ const createTrack = useCallback(
347
+ (preset: MotionPropertyPreset) => {
348
+ if (!onTracksChange || !selectedTarget) return;
349
+ const { nodeId, label } = selectedTarget;
350
+
351
+ // Always expand the target layer so the new track row is visible.
352
+ setExpandedNodeIds((prev) => {
353
+ const next = new Set(prev);
354
+ next.add(nodeId);
355
+ return next;
356
+ });
357
+
358
+ if (hasTrackFor(tracks, nodeId, preset.property)) {
359
+ // Track already exists — do not duplicate; the expand above surfaces it.
360
+ return;
361
+ }
362
+
363
+ const seeded = createMotionTrackFromPreset(nodeId, preset, defaultEase);
364
+ const newTrack: MotionDockTrack = { ...seeded, label };
365
+ onTracksChange([...tracks, newTrack]);
366
+ },
367
+ [defaultEase, onTracksChange, selectedTarget, tracks],
368
+ );
369
+
370
+ const deleteKeyframe = useCallback(
371
+ (track: MotionDockTrack, kf: MotionKeyframe) => {
372
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
373
+ ...tr,
374
+ keyframes: tr.keyframes.filter((k) => k !== kf),
375
+ }));
376
+ },
377
+ [updateTrack],
378
+ );
379
+
380
+ // ── Ruler tick marks ──────────────────────────────────────────────────────
381
+ function rulerTicks(): { t: number; label: string }[] {
382
+ const count = 10;
383
+ return Array.from({ length: count + 1 }, (_, i) => {
384
+ const t = i / count;
385
+ const ms = Math.round(t * durationMs);
386
+ const s = (ms / 1000).toFixed(1);
387
+ return { t, label: `${s}s` };
388
+ });
389
+ }
390
+
391
+ // ── Group tracks by layer (nodeId) ────────────────────────────────────────
392
+ type LayerGroup = {
393
+ nodeId: string;
394
+ label: string;
395
+ tracks: MotionDockTrack[];
396
+ };
397
+ const layers: LayerGroup[] = tracks.reduce<LayerGroup[]>((acc, track) => {
398
+ const existing = acc.find((g) => g.nodeId === track.targetNodeId);
399
+ if (existing) {
400
+ existing.tracks.push(track);
401
+ } else {
402
+ acc.push({
403
+ nodeId: track.targetNodeId,
404
+ label: track.label,
405
+ tracks: [track],
406
+ });
407
+ }
408
+ return acc;
409
+ }, []);
410
+
411
+ // ── Render ────────────────────────────────────────────────────────────────
412
+ return (
413
+ <div
414
+ aria-label="Motion dock"
415
+ className={cn(
416
+ "flex flex-col border-t border-border bg-background transition-all duration-150 select-none",
417
+ isOpen ? "" : "h-8",
418
+ )}
419
+ style={isOpen ? { height: dockHeight } : undefined}
420
+ >
421
+ {/* Resize handle */}
422
+ {isOpen && (
423
+ <div
424
+ className="absolute -top-1 left-0 right-0 h-2 cursor-ns-resize z-10"
425
+ onPointerDown={handleResizePointerDown}
426
+ onPointerMove={handleResizePointerMove}
427
+ onPointerUp={handleResizePointerUp}
428
+ />
429
+ )}
430
+
431
+ {/* Dock toolbar */}
432
+ <div className="flex h-8 shrink-0 items-center gap-1 border-b border-border px-2">
433
+ {/* Collapse toggle */}
434
+ <Button
435
+ type="button"
436
+ variant="ghost"
437
+ size="icon"
438
+ className="size-6 shrink-0"
439
+ onClick={() => setOpen(!isOpen)}
440
+ aria-label={isOpen ? "Collapse motion dock" : "Expand motion dock"}
441
+ >
442
+ {isOpen ? (
443
+ <IconChevronDown className="size-3.5" />
444
+ ) : (
445
+ <IconChevronRight className="size-3.5" />
446
+ )}
447
+ </Button>
448
+
449
+ <span className="text-[11px] font-medium tracking-wide text-muted-foreground uppercase mr-1">
450
+ Motion
451
+ </span>
452
+
453
+ {isOpen && (
454
+ <>
455
+ {/* Play / Pause */}
456
+ <Tooltip>
457
+ <TooltipTrigger asChild>
458
+ <Button
459
+ type="button"
460
+ variant="ghost"
461
+ size="icon"
462
+ className="size-6 shrink-0"
463
+ onClick={playing ? stopPlayback : startPlayback}
464
+ aria-label={playing ? "Pause" : "Play"}
465
+ >
466
+ {playing ? (
467
+ <IconPlayerPause className="size-3.5" />
468
+ ) : (
469
+ <IconPlayerPlay className="size-3.5" />
470
+ )}
471
+ </Button>
472
+ </TooltipTrigger>
473
+ <TooltipContent side="top">
474
+ {playing ? "Pause" : "Play"}
475
+ </TooltipContent>
476
+ </Tooltip>
477
+
478
+ {/* Stop / reset */}
479
+ <Tooltip>
480
+ <TooltipTrigger asChild>
481
+ <Button
482
+ type="button"
483
+ variant="ghost"
484
+ size="icon"
485
+ className="size-6 shrink-0"
486
+ onClick={() => {
487
+ stopPlayback();
488
+ setPlayhead(0);
489
+ sendPreview(0);
490
+ }}
491
+ aria-label="Reset playhead"
492
+ >
493
+ <IconPlayerStop className="size-3.5" />
494
+ </Button>
495
+ </TooltipTrigger>
496
+ <TooltipContent side="top">Reset</TooltipContent>
497
+ </Tooltip>
498
+
499
+ {/* Duration */}
500
+ <div className="flex items-center gap-1 ml-1">
501
+ <span className="text-[10px] text-muted-foreground">
502
+ Duration
503
+ </span>
504
+ <Input
505
+ type="number"
506
+ min={50}
507
+ step={50}
508
+ value={durationInput}
509
+ onChange={(e) => setDurationInput(e.target.value)}
510
+ onBlur={() => {
511
+ const ms = parseInt(durationInput, 10);
512
+ if (!isNaN(ms) && ms >= 50) {
513
+ onDurationChange?.(ms);
514
+ } else {
515
+ setDurationInput(String(durationMs));
516
+ }
517
+ }}
518
+ className="h-5 w-16 px-1 text-[11px]"
519
+ aria-label="Duration in ms"
520
+ />
521
+ <span className="text-[10px] text-muted-foreground">ms</span>
522
+ </div>
523
+
524
+ {/* Add track — the "create first track" entry point. */}
525
+ <div className="ml-2">
526
+ <AddTrackMenu
527
+ selectedTarget={selectedTarget}
528
+ onCreateTrack={createTrack}
529
+ />
530
+ </div>
531
+
532
+ <div className="ml-auto flex items-center gap-1">
533
+ {/* Auto-keyframe toggle */}
534
+ <Tooltip>
535
+ <TooltipTrigger asChild>
536
+ <Button
537
+ type="button"
538
+ variant={autoKeyframe ? "secondary" : "ghost"}
539
+ size="icon"
540
+ className={cn(
541
+ "size-6 shrink-0",
542
+ autoKeyframe && "text-primary",
543
+ )}
544
+ onClick={() => setAutoKeyframe((v) => !v)}
545
+ aria-label="Toggle auto-keyframe"
546
+ aria-pressed={autoKeyframe}
547
+ >
548
+ <IconBolt className="size-3.5" />
549
+ </Button>
550
+ </TooltipTrigger>
551
+ <TooltipContent side="top">Auto-keyframe</TooltipContent>
552
+ </Tooltip>
553
+
554
+ {/* Write to CSS */}
555
+ <Tooltip>
556
+ <TooltipTrigger asChild>
557
+ <Button
558
+ type="button"
559
+ size="sm"
560
+ className="h-6 px-2.5 text-[11px] gap-1"
561
+ disabled={applying || tracks.length === 0}
562
+ onClick={() => onApply?.(tracks, durationMs)}
563
+ >
564
+ {applying ? (
565
+ <IconRefresh className="size-3 animate-spin" />
566
+ ) : (
567
+ <IconCode className="size-3" />
568
+ )}
569
+ Write to CSS
570
+ </Button>
571
+ </TooltipTrigger>
572
+ <TooltipContent side="top">
573
+ Compile timeline → managed &lt;style
574
+ data-agent-native-motion&gt; block
575
+ </TooltipContent>
576
+ </Tooltip>
577
+ </div>
578
+ </>
579
+ )}
580
+ </div>
581
+
582
+ {/* Dock body */}
583
+ {isOpen && (
584
+ <div className="flex flex-1 overflow-hidden">
585
+ {/* Layer sidebar */}
586
+ <div
587
+ className="flex flex-col shrink-0 border-r border-border overflow-y-auto"
588
+ style={{ width: LAYER_SIDEBAR_WIDTH }}
589
+ >
590
+ {/* Ruler spacer */}
591
+ <div
592
+ style={{ height: RULER_HEIGHT }}
593
+ className="border-b border-border"
594
+ />
595
+
596
+ {layers.length === 0 ? (
597
+ <div className="flex flex-col items-center justify-center flex-1 gap-3 text-center px-4 py-6">
598
+ <IconLayersSubtract className="size-5 text-muted-foreground/40" />
599
+ {selectedTarget ? (
600
+ <>
601
+ <p className="text-[11px] text-muted-foreground/70 leading-snug">
602
+ Animate{" "}
603
+ <span className="font-medium text-foreground/80">
604
+ {selectedTarget.label}
605
+ </span>
606
+ . Pick a property to add the first track.
607
+ </p>
608
+ <AddTrackMenu
609
+ selectedTarget={selectedTarget}
610
+ onCreateTrack={createTrack}
611
+ variant="cta"
612
+ />
613
+ </>
614
+ ) : (
615
+ <p className="text-[11px] text-muted-foreground/70 leading-snug">
616
+ Select an element on the canvas, then add a track to animate
617
+ it.
618
+ </p>
619
+ )}
620
+ </div>
621
+ ) : (
622
+ layers.map((layer) => (
623
+ <LayerGroup
624
+ key={layer.nodeId}
625
+ layer={layer}
626
+ expanded={expandedNodeIds.has(layer.nodeId)}
627
+ onToggleExpand={() =>
628
+ setExpandedNodeIds((prev) => {
629
+ const next = new Set(prev);
630
+ if (next.has(layer.nodeId)) next.delete(layer.nodeId);
631
+ else next.add(layer.nodeId);
632
+ return next;
633
+ })
634
+ }
635
+ onAddKeyframe={(track) => addKeyframe(track)}
636
+ />
637
+ ))
638
+ )}
639
+ </div>
640
+
641
+ {/* Timeline / track area */}
642
+ <div className="flex flex-col flex-1 overflow-hidden">
643
+ {/* Ruler + playhead drag */}
644
+ <div
645
+ ref={trackAreaRef}
646
+ className="relative shrink-0 border-b border-border cursor-col-resize"
647
+ style={{ height: RULER_HEIGHT }}
648
+ onPointerDown={handleRulerPointerDown}
649
+ onPointerMove={handleRulerPointerMove}
650
+ onPointerUp={handleRulerPointerUp}
651
+ >
652
+ {/* Tick marks */}
653
+ {rulerTicks().map(({ t, label }) => (
654
+ <div
655
+ key={t}
656
+ className="absolute top-0 flex flex-col items-center pointer-events-none"
657
+ style={{ left: `${t * 100}%`, transform: "translateX(-50%)" }}
658
+ >
659
+ <span className="text-[9px] text-muted-foreground/60 leading-none mt-1">
660
+ {label}
661
+ </span>
662
+ <div className="w-px h-2 bg-border mt-0.5" />
663
+ </div>
664
+ ))}
665
+
666
+ {/* Playhead */}
667
+ <PlayheadLine t={playhead} height={RULER_HEIGHT} inRuler />
668
+ </div>
669
+
670
+ {/* Track rows with keyframe diamonds */}
671
+ <div className="relative flex-1 overflow-y-auto">
672
+ {layers.map((layer) => (
673
+ <LayerTrackRows
674
+ key={layer.nodeId}
675
+ layer={layer}
676
+ expanded={expandedNodeIds.has(layer.nodeId)}
677
+ playhead={playhead}
678
+ onDeleteKeyframe={deleteKeyframe}
679
+ onMoveKeyframe={(track, kf, newT) => {
680
+ updateTrack(track.targetNodeId, track.property, (tr) => ({
681
+ ...tr,
682
+ keyframes: tr.keyframes.map((k) =>
683
+ k === kf ? { ...k, t: newT } : k,
684
+ ),
685
+ }));
686
+ }}
687
+ />
688
+ ))}
689
+
690
+ {/* Playhead across track rows */}
691
+ <PlayheadLine
692
+ t={playhead}
693
+ height={layers.reduce(
694
+ (sum, layer) =>
695
+ sum +
696
+ ROW_HEIGHT +
697
+ (expandedNodeIds.has(layer.nodeId)
698
+ ? layer.tracks.length * ROW_HEIGHT
699
+ : 0),
700
+ 0,
701
+ )}
702
+ />
703
+ </div>
704
+ </div>
705
+ </div>
706
+ )}
707
+ </div>
708
+ );
709
+ }
710
+
711
+ // ─── Sub-components ────────────────────────────────────────────────────────────
712
+
713
+ interface AddTrackMenuProps {
714
+ selectedTarget: { nodeId: string; label: string } | null;
715
+ onCreateTrack: (preset: MotionPropertyPreset) => void;
716
+ /** "toolbar" (compact button) or "cta" (prominent empty-state button). */
717
+ variant?: "toolbar" | "cta";
718
+ }
719
+
720
+ /**
721
+ * Property-preset dropdown that creates a new motion track for the selected
722
+ * element. Disabled (with a hint) when nothing is selected, which is the only
723
+ * state where a first track cannot be created.
724
+ */
725
+ function AddTrackMenu({
726
+ selectedTarget,
727
+ onCreateTrack,
728
+ variant = "toolbar",
729
+ }: AddTrackMenuProps) {
730
+ const disabled = !selectedTarget;
731
+ const trigger =
732
+ variant === "cta" ? (
733
+ <Button
734
+ type="button"
735
+ size="sm"
736
+ variant="secondary"
737
+ className="h-7 gap-1 text-[11px]"
738
+ disabled={disabled}
739
+ >
740
+ <IconPlus className="size-3.5" />
741
+ Add track
742
+ </Button>
743
+ ) : (
744
+ <Button
745
+ type="button"
746
+ size="sm"
747
+ variant="ghost"
748
+ className="h-6 gap-1 px-2 text-[11px]"
749
+ disabled={disabled}
750
+ >
751
+ <IconPlus className="size-3.5" />
752
+ Add track
753
+ </Button>
754
+ );
755
+
756
+ // When disabled, render a tooltip-wrapped static button instead of a menu so
757
+ // the user learns they need a selection first.
758
+ if (disabled) {
759
+ return (
760
+ <Tooltip>
761
+ <TooltipTrigger asChild>
762
+ <span className="inline-flex">{trigger}</span>
763
+ </TooltipTrigger>
764
+ <TooltipContent side="top">
765
+ Select an element on the canvas first
766
+ </TooltipContent>
767
+ </Tooltip>
768
+ );
769
+ }
770
+
771
+ return (
772
+ <DropdownMenu>
773
+ <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
774
+ <DropdownMenuContent align="start" className="w-52">
775
+ <DropdownMenuLabel className="truncate text-[10px] text-muted-foreground">
776
+ Animate “{selectedTarget.label}”
777
+ </DropdownMenuLabel>
778
+ <DropdownMenuSeparator />
779
+ {MOTION_PROPERTY_PRESETS.map((preset) => (
780
+ <DropdownMenuItem
781
+ key={`${preset.property}-${preset.label}`}
782
+ className="text-[12px]"
783
+ onSelect={() => onCreateTrack(preset)}
784
+ >
785
+ <IconDiamond className="size-3 text-primary/70" />
786
+ {preset.label}
787
+ </DropdownMenuItem>
788
+ ))}
789
+ </DropdownMenuContent>
790
+ </DropdownMenu>
791
+ );
792
+ }
793
+
794
+ interface PlayheadLineProps {
795
+ t: number;
796
+ height: number;
797
+ inRuler?: boolean;
798
+ }
799
+
800
+ function PlayheadLine({ t, height, inRuler }: PlayheadLineProps) {
801
+ return (
802
+ <div
803
+ className={cn(
804
+ "absolute top-0 pointer-events-none z-10",
805
+ inRuler ? "bg-primary/80" : "bg-primary/60",
806
+ )}
807
+ style={{
808
+ left: `${t * 100}%`,
809
+ width: PLAYHEAD_WIDTH,
810
+ height,
811
+ transform: "translateX(-50%)",
812
+ }}
813
+ >
814
+ {inRuler && (
815
+ <div className="absolute -top-0 left-1/2 -translate-x-1/2 size-2 rounded-sm bg-primary" />
816
+ )}
817
+ </div>
818
+ );
819
+ }
820
+
821
+ interface LayerGroupProps {
822
+ layer: { nodeId: string; label: string; tracks: MotionDockTrack[] };
823
+ expanded: boolean;
824
+ onToggleExpand: () => void;
825
+ onAddKeyframe: (track: MotionDockTrack) => void;
826
+ }
827
+
828
+ function LayerGroup({
829
+ layer,
830
+ expanded,
831
+ onToggleExpand,
832
+ onAddKeyframe,
833
+ }: LayerGroupProps) {
834
+ return (
835
+ <>
836
+ {/* Layer header row */}
837
+ <div
838
+ className="flex items-center gap-1 px-2 hover:bg-accent/40 cursor-pointer"
839
+ style={{ height: ROW_HEIGHT }}
840
+ onClick={onToggleExpand}
841
+ role="button"
842
+ tabIndex={0}
843
+ onKeyDown={(e) => {
844
+ if (e.key === "Enter" || e.key === " ") onToggleExpand();
845
+ }}
846
+ aria-expanded={expanded}
847
+ >
848
+ <span className="shrink-0 text-muted-foreground">
849
+ {expanded ? (
850
+ <IconChevronDown className="size-3" />
851
+ ) : (
852
+ <IconChevronRight className="size-3" />
853
+ )}
854
+ </span>
855
+ <span
856
+ className="flex-1 truncate text-[11px] font-medium"
857
+ title={layer.label}
858
+ >
859
+ {layer.label}
860
+ </span>
861
+ </div>
862
+
863
+ {/* Property sub-rows */}
864
+ {expanded &&
865
+ layer.tracks.map((track) => (
866
+ <div
867
+ key={`${track.targetNodeId}-${track.property}`}
868
+ className="group flex items-center gap-1 pl-5 pr-2 hover:bg-accent/20"
869
+ style={{ height: ROW_HEIGHT }}
870
+ >
871
+ <IconDiamond className="size-2.5 shrink-0 text-primary/70" />
872
+ <span className="flex-1 truncate text-[10px] text-muted-foreground">
873
+ {track.property}
874
+ </span>
875
+ <Tooltip>
876
+ <TooltipTrigger asChild>
877
+ <Button
878
+ type="button"
879
+ variant="ghost"
880
+ size="icon"
881
+ className="size-5 shrink-0 opacity-0 group-hover:opacity-100"
882
+ onClick={(e) => {
883
+ e.stopPropagation();
884
+ onAddKeyframe(track);
885
+ }}
886
+ aria-label="Add keyframe"
887
+ >
888
+ <IconPlus className="size-3" />
889
+ </Button>
890
+ </TooltipTrigger>
891
+ <TooltipContent side="right">
892
+ Add keyframe at playhead
893
+ </TooltipContent>
894
+ </Tooltip>
895
+ </div>
896
+ ))}
897
+ </>
898
+ );
899
+ }
900
+
901
+ interface LayerTrackRowsProps {
902
+ layer: { nodeId: string; label: string; tracks: MotionDockTrack[] };
903
+ expanded: boolean;
904
+ playhead: number;
905
+ onDeleteKeyframe: (track: MotionDockTrack, kf: MotionKeyframe) => void;
906
+ onMoveKeyframe: (
907
+ track: MotionDockTrack,
908
+ kf: MotionKeyframe,
909
+ newT: number,
910
+ ) => void;
911
+ }
912
+
913
+ function LayerTrackRows({
914
+ layer,
915
+ expanded,
916
+ playhead: _playhead,
917
+ onDeleteKeyframe,
918
+ onMoveKeyframe,
919
+ }: LayerTrackRowsProps) {
920
+ return (
921
+ <>
922
+ {/* Layer header spacer row */}
923
+ <div
924
+ className="relative border-b border-border/40"
925
+ style={{ height: ROW_HEIGHT }}
926
+ />
927
+
928
+ {/* Property track rows */}
929
+ {expanded &&
930
+ layer.tracks.map((track) => (
931
+ <TrackRow
932
+ key={`${track.targetNodeId}-${track.property}`}
933
+ track={track}
934
+ onDeleteKeyframe={(kf) => onDeleteKeyframe(track, kf)}
935
+ onMoveKeyframe={(kf, newT) => onMoveKeyframe(track, kf, newT)}
936
+ />
937
+ ))}
938
+ </>
939
+ );
940
+ }
941
+
942
+ interface TrackRowProps {
943
+ track: MotionDockTrack;
944
+ onDeleteKeyframe: (kf: MotionKeyframe) => void;
945
+ onMoveKeyframe: (kf: MotionKeyframe, newT: number) => void;
946
+ }
947
+
948
+ function TrackRow({ track, onDeleteKeyframe, onMoveKeyframe }: TrackRowProps) {
949
+ const rowRef = useRef<HTMLDivElement>(null);
950
+ const dragging = useRef<{
951
+ kf: MotionKeyframe;
952
+ startX: number;
953
+ startT: number;
954
+ } | null>(null);
955
+
956
+ const handleDiamondPointerDown = useCallback(
957
+ (e: ReactPointerEvent<SVGSVGElement>, kf: MotionKeyframe) => {
958
+ e.stopPropagation();
959
+ dragging.current = { kf, startX: e.clientX, startT: kf.t };
960
+ (e.target as Element).setPointerCapture(e.pointerId);
961
+ },
962
+ [],
963
+ );
964
+
965
+ const handlePointerMove = useCallback(
966
+ (e: ReactPointerEvent<HTMLDivElement>) => {
967
+ if (!dragging.current || !rowRef.current) return;
968
+ const rect = rowRef.current.getBoundingClientRect();
969
+ const dx = e.clientX - dragging.current.startX;
970
+ const dt = dx / rect.width;
971
+ const newT = Math.max(0, Math.min(1, dragging.current.startT + dt));
972
+ onMoveKeyframe(dragging.current.kf, newT);
973
+ },
974
+ [onMoveKeyframe],
975
+ );
976
+
977
+ const handlePointerUp = useCallback(() => {
978
+ dragging.current = null;
979
+ }, []);
980
+
981
+ return (
982
+ <div
983
+ ref={rowRef}
984
+ className="relative border-b border-border/30"
985
+ style={{ height: ROW_HEIGHT }}
986
+ onPointerMove={handlePointerMove}
987
+ onPointerUp={handlePointerUp}
988
+ >
989
+ {/* Track baseline */}
990
+ <div className="absolute inset-y-1/2 left-0 right-0 h-px bg-border/50" />
991
+
992
+ {/* Keyframe diamonds */}
993
+ {track.keyframes.map((kf, i) => (
994
+ <KeyframeDiamond
995
+ key={i}
996
+ kf={kf}
997
+ onPointerDown={(e) => handleDiamondPointerDown(e, kf)}
998
+ onDelete={() => onDeleteKeyframe(kf)}
999
+ />
1000
+ ))}
1001
+ </div>
1002
+ );
1003
+ }
1004
+
1005
+ interface KeyframeDiamondProps {
1006
+ kf: MotionKeyframe;
1007
+ onPointerDown: (e: ReactPointerEvent<SVGSVGElement>) => void;
1008
+ onDelete: () => void;
1009
+ }
1010
+
1011
+ function KeyframeDiamond({
1012
+ kf,
1013
+ onPointerDown,
1014
+ onDelete,
1015
+ }: KeyframeDiamondProps) {
1016
+ const [hovered, setHovered] = useState(false);
1017
+
1018
+ return (
1019
+ <div
1020
+ className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 group"
1021
+ style={{ left: `${kf.t * 100}%` }}
1022
+ >
1023
+ <Tooltip>
1024
+ <TooltipTrigger asChild>
1025
+ <svg
1026
+ width={10}
1027
+ height={10}
1028
+ viewBox="0 0 10 10"
1029
+ className="cursor-grab active:cursor-grabbing"
1030
+ onPointerDown={onPointerDown}
1031
+ onPointerEnter={() => setHovered(true)}
1032
+ onPointerLeave={() => setHovered(false)}
1033
+ aria-label={`Keyframe at ${Math.round(kf.t * 100)}%`}
1034
+ >
1035
+ <rect
1036
+ x={1}
1037
+ y={1}
1038
+ width={8}
1039
+ height={8}
1040
+ rx={1}
1041
+ transform="rotate(45 5 5)"
1042
+ className={cn(
1043
+ "transition-colors",
1044
+ hovered
1045
+ ? "fill-primary stroke-primary-foreground"
1046
+ : "fill-primary/70 stroke-primary/30",
1047
+ )}
1048
+ strokeWidth={1}
1049
+ />
1050
+ </svg>
1051
+ </TooltipTrigger>
1052
+ <TooltipContent
1053
+ side="top"
1054
+ className="flex items-center gap-1 text-[10px]"
1055
+ >
1056
+ <span>
1057
+ {Math.round(kf.t * 100)}% — {kf.value}
1058
+ </span>
1059
+ <button
1060
+ type="button"
1061
+ className="ml-1 text-destructive hover:text-destructive/80"
1062
+ onClick={onDelete}
1063
+ aria-label="Delete keyframe"
1064
+ >
1065
+ <IconTrash className="size-3" />
1066
+ </button>
1067
+ </TooltipContent>
1068
+ </Tooltip>
1069
+ </div>
1070
+ );
1071
+ }