@agent-native/core 0.80.9 → 0.80.10

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