@hyperframes/studio 0.6.59 → 0.6.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.html CHANGED
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
7
7
  <title>HyperFrames Studio</title>
8
- <script type="module" crossorigin src="/assets/index-B1XH-ptc.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-DH9QNjuX.css">
8
+ <script type="module" crossorigin src="/assets/index-BdDNthf4.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B5EnhVCT.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.6.59",
3
+ "version": "0.6.61",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -31,8 +31,8 @@
31
31
  "@codemirror/view": "6.40.0",
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "mediabunny": "^1.45.3",
34
- "@hyperframes/core": "0.6.59",
35
- "@hyperframes/player": "0.6.59"
34
+ "@hyperframes/core": "0.6.61",
35
+ "@hyperframes/player": "0.6.61"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/react": "19",
@@ -46,7 +46,7 @@
46
46
  "vite": "^6.4.2",
47
47
  "vitest": "^3.2.4",
48
48
  "zustand": "^5.0.0",
49
- "@hyperframes/producer": "0.6.59"
49
+ "@hyperframes/producer": "0.6.61"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -26,10 +26,11 @@ import { useCompositionDimensions } from "./hooks/useCompositionDimensions";
26
26
  import { useToast } from "./hooks/useToast";
27
27
  import { useStudioUrlState } from "./hooks/useStudioUrlState";
28
28
  import {
29
- STUDIO_INSPECTOR_PANELS_ENABLED,
30
- STUDIO_MOTION_PANEL_ENABLED,
31
- } from "./components/editor/manualEditingAvailability";
32
- import { readStudioMotionFromElement } from "./components/editor/studioMotion";
29
+ buildStudioContextValue,
30
+ useDragOverlay,
31
+ useInspectorState,
32
+ } from "./hooks/useStudioContextValue";
33
+ import { buildAgentContextPreview } from "./components/editor/domEditingAgentPrompt";
33
34
  import type { DomEditSelection } from "./components/editor/domEditing";
34
35
  import { AskAgentModal } from "./components/AskAgentModal";
35
36
  import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay";
@@ -38,7 +39,7 @@ import { StudioLeftSidebar } from "./components/StudioLeftSidebar";
38
39
  import { StudioPreviewArea } from "./components/StudioPreviewArea";
39
40
  import { StudioRightPanel } from "./components/StudioRightPanel";
40
41
  import { TimelineToolbar } from "./components/TimelineToolbar";
41
- import { StudioProvider, type StudioContextValue } from "./contexts/StudioContext";
42
+ import { StudioProvider } from "./contexts/StudioContext";
42
43
  import { PanelLayoutProvider } from "./contexts/PanelLayoutContext";
43
44
  import { FileManagerProvider } from "./contexts/FileManagerContext";
44
45
  import { DomEditProvider } from "./contexts/DomEditContext";
@@ -51,6 +52,7 @@ import {
51
52
  import { trackStudioSessionStart } from "./telemetry/events";
52
53
  import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
53
54
 
55
+ // fallow-ignore-next-line complexity
54
56
  export function StudioApp() {
55
57
  const { projectId, resolving, waitingForServer } = useServerConnection();
56
58
  const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
@@ -184,6 +186,26 @@ export function StudioApp() {
184
186
  uploadProjectFiles: fileManager.uploadProjectFiles,
185
187
  });
186
188
 
189
+ const blockCtx = useMemo(
190
+ () => ({
191
+ activeCompPath,
192
+ timelineElements,
193
+ readProjectFile: fileManager.readProjectFile,
194
+ writeProjectFile: fileManager.writeProjectFile,
195
+ recordEdit: editHistory.recordEdit,
196
+ refreshFileTree: fileManager.refreshFileTree,
197
+ reloadPreview,
198
+ showToast,
199
+ }),
200
+ [
201
+ activeCompPath,
202
+ timelineElements,
203
+ fileManager,
204
+ editHistory.recordEdit,
205
+ reloadPreview,
206
+ showToast,
207
+ ],
208
+ );
187
209
  const handleAddBlock = useCallback(
188
210
  (blockName: string) => {
189
211
  if (!projectId) return;
@@ -191,16 +213,9 @@ export function StudioApp() {
191
213
  const result = await addBlockToProject({
192
214
  projectId,
193
215
  blockName,
194
- activeCompPath,
216
+ ...blockCtx,
195
217
  previewIframe: previewIframeRef.current,
196
218
  currentTime: usePlayerStore.getState().currentTime,
197
- timelineElements,
198
- readProjectFile: fileManager.readProjectFile,
199
- writeProjectFile: fileManager.writeProjectFile,
200
- recordEdit: editHistory.recordEdit,
201
- refreshFileTree: fileManager.refreshFileTree,
202
- reloadPreview,
203
- showToast,
204
219
  });
205
220
  const params = result?.block.type === "hyperframes:block" ? result.block.params : undefined;
206
221
  if (params?.length) {
@@ -215,82 +230,35 @@ export function StudioApp() {
215
230
  }
216
231
  })();
217
232
  },
218
- [
219
- projectId,
220
- activeCompPath,
221
- timelineElements,
222
- fileManager.readProjectFile,
223
- fileManager.writeProjectFile,
224
- fileManager.refreshFileTree,
225
- editHistory.recordEdit,
226
- reloadPreview,
227
- showToast,
228
- panelLayout,
229
- ],
233
+ [projectId, blockCtx, panelLayout],
230
234
  );
231
-
232
235
  const handleTimelineBlockDrop = useCallback(
233
236
  (blockName: string, placement: { start: number; track: number }) => {
234
237
  if (!projectId) return;
235
238
  void addBlockToProject({
236
239
  projectId,
237
240
  blockName,
238
- activeCompPath,
239
241
  placement,
242
+ ...blockCtx,
240
243
  previewIframe: previewIframeRef.current,
241
244
  currentTime: usePlayerStore.getState().currentTime,
242
- timelineElements,
243
- readProjectFile: fileManager.readProjectFile,
244
- writeProjectFile: fileManager.writeProjectFile,
245
- recordEdit: editHistory.recordEdit,
246
- refreshFileTree: fileManager.refreshFileTree,
247
- reloadPreview,
248
- showToast,
249
245
  });
250
246
  },
251
- [
252
- projectId,
253
- activeCompPath,
254
- timelineElements,
255
- fileManager.readProjectFile,
256
- fileManager.writeProjectFile,
257
- fileManager.refreshFileTree,
258
- editHistory.recordEdit,
259
- reloadPreview,
260
- showToast,
261
- ],
247
+ [projectId, blockCtx],
262
248
  );
263
-
264
249
  const handlePreviewBlockDrop = useCallback(
265
250
  (blockName: string, position: { left: number; top: number }) => {
266
251
  if (!projectId) return;
267
252
  void addBlockToProject({
268
253
  projectId,
269
254
  blockName,
270
- activeCompPath,
271
255
  visualPosition: position,
256
+ ...blockCtx,
272
257
  previewIframe: previewIframeRef.current,
273
258
  currentTime: usePlayerStore.getState().currentTime,
274
- timelineElements,
275
- readProjectFile: fileManager.readProjectFile,
276
- writeProjectFile: fileManager.writeProjectFile,
277
- recordEdit: editHistory.recordEdit,
278
- refreshFileTree: fileManager.refreshFileTree,
279
- reloadPreview,
280
- showToast,
281
259
  });
282
260
  },
283
- [
284
- projectId,
285
- activeCompPath,
286
- timelineElements,
287
- fileManager.readProjectFile,
288
- fileManager.writeProjectFile,
289
- fileManager.refreshFileTree,
290
- editHistory.recordEdit,
291
- reloadPreview,
292
- showToast,
293
- ],
261
+ [projectId, blockCtx],
294
262
  );
295
263
 
296
264
  const clearDomSelectionRef = useRef<() => void>(() => {});
@@ -414,30 +382,22 @@ export function StudioApp() {
414
382
  resetErrors: resetConsoleErrors,
415
383
  } = useConsoleErrorCapture(previewIframe);
416
384
 
417
- const [globalDragOver, setGlobalDragOver] = useState(false);
418
- const dragCounterRef = useRef(0);
385
+ const dragOverlay = useDragOverlay(fileManager.handleImportFiles);
419
386
 
420
- const { syncPreviewTimelineHotkey, syncPreviewHistoryHotkey } = appHotkeys;
421
387
  const handlePreviewIframeRef = useCallback(
422
388
  (iframe: HTMLIFrameElement | null) => {
423
389
  previewIframeRef.current = iframe;
424
390
  setPreviewIframe(iframe);
425
- syncPreviewTimelineHotkey(iframe);
426
- syncPreviewHistoryHotkey(iframe);
391
+ appHotkeys.syncPreviewTimelineHotkey(iframe);
392
+ appHotkeys.syncPreviewHistoryHotkey(iframe);
427
393
  resetConsoleErrors();
428
394
  refreshPreviewDocumentVersion();
429
395
  },
430
- [
431
- refreshPreviewDocumentVersion,
432
- resetConsoleErrors,
433
- syncPreviewHistoryHotkey,
434
- syncPreviewTimelineHotkey,
435
- ],
396
+ [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion],
436
397
  );
437
-
438
398
  const handleSelectComposition = useCallback(
439
399
  (comp: string) => {
440
- setActiveCompPath(comp === "index.html" || comp.startsWith("compositions/") ? comp : null);
400
+ setActiveCompPath(comp.endsWith(".html") ? comp : null);
441
401
  fileManager.setEditingFile({ path: comp, content: null });
442
402
  fetch(`/api/projects/${projectId}/files/${comp}`)
443
403
  .then((r) => r.json())
@@ -447,23 +407,19 @@ export function StudioApp() {
447
407
  [projectId, fileManager],
448
408
  );
449
409
 
450
- const selectedStudioMotion =
451
- STUDIO_INSPECTOR_PANELS_ENABLED && domEditSession.domEditSelection
452
- ? readStudioMotionFromElement(domEditSession.domEditSelection.element)
453
- : null;
454
- const layersPanelActive =
455
- STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "layers";
456
- const designPanelActive =
457
- STUDIO_INSPECTOR_PANELS_ENABLED && panelLayout.rightPanelTab === "design";
458
- const motionPanelActive =
459
- STUDIO_INSPECTOR_PANELS_ENABLED &&
460
- STUDIO_MOTION_PANEL_ENABLED &&
461
- panelLayout.rightPanelTab === "motion";
462
- const inspectorPanelActive = layersPanelActive || designPanelActive || motionPanelActive;
463
- const shouldShowSelectedDomBounds =
464
- inspectorPanelActive && !panelLayout.rightCollapsed && !isPlaying;
465
- const inspectorButtonActive =
466
- STUDIO_INSPECTOR_PANELS_ENABLED && !panelLayout.rightCollapsed && inspectorPanelActive;
410
+ const {
411
+ selectedStudioMotion,
412
+ designPanelActive,
413
+ motionPanelActive,
414
+ inspectorPanelActive,
415
+ inspectorButtonActive,
416
+ shouldShowSelectedDomBounds,
417
+ } = useInspectorState(
418
+ panelLayout.rightPanelTab,
419
+ panelLayout.rightCollapsed,
420
+ isPlaying,
421
+ domEditSession.domEditSelection,
422
+ );
467
423
 
468
424
  useStudioUrlState({
469
425
  projectId,
@@ -484,8 +440,7 @@ export function StudioApp() {
484
440
  initialState: initialUrlStateRef.current,
485
441
  });
486
442
 
487
- // StudioProvider performs its own useMemo — no need for a second memo here.
488
- const studioCtxValue: StudioContextValue = {
443
+ const studioCtxValue = buildStudioContextValue({
489
444
  projectId: projectId!,
490
445
  activeCompPath,
491
446
  setActiveCompPath,
@@ -498,12 +453,7 @@ export function StudioApp() {
498
453
  currentTime,
499
454
  timelineElements,
500
455
  isPlaying,
501
- editHistory: {
502
- canUndo: editHistory.canUndo,
503
- canRedo: editHistory.canRedo,
504
- undoLabel: editHistory.undoLabel,
505
- redoLabel: editHistory.redoLabel,
506
- },
456
+ editHistory,
507
457
  handleUndo: appHotkeys.handleUndo,
508
458
  handleRedo: appHotkeys.handleRedo,
509
459
  renderQueue: {
@@ -519,7 +469,7 @@ export function StudioApp() {
519
469
  refreshPreviewDocumentVersion,
520
470
  timelineVisible,
521
471
  toggleTimelineVisibility,
522
- };
472
+ });
523
473
 
524
474
  if (resolving || waitingForServer || !projectId) {
525
475
  return <StudioSplash waiting={waitingForServer} />;
@@ -533,28 +483,10 @@ export function StudioApp() {
533
483
  <DomEditProvider value={domEditSession}>
534
484
  <div
535
485
  className="flex flex-col h-full w-full bg-neutral-950 relative"
536
- onDragOver={(e) => {
537
- if (!e.dataTransfer.types.includes("Files")) return;
538
- e.preventDefault();
539
- }}
540
- onDragEnter={(e) => {
541
- if (!e.dataTransfer.types.includes("Files")) return;
542
- e.preventDefault();
543
- dragCounterRef.current++;
544
- setGlobalDragOver(true);
545
- }}
546
- onDragLeave={() => {
547
- dragCounterRef.current--;
548
- if (dragCounterRef.current === 0) setGlobalDragOver(false);
549
- }}
550
- onDrop={(e) => {
551
- dragCounterRef.current = 0;
552
- setGlobalDragOver(false);
553
- if (e.defaultPrevented) return;
554
- e.preventDefault();
555
- if (e.dataTransfer.files.length)
556
- fileManager.handleImportFiles(e.dataTransfer.files);
557
- }}
486
+ onDragOver={dragOverlay.onDragOver}
487
+ onDragEnter={dragOverlay.onDragEnter}
488
+ onDragLeave={dragOverlay.onDragLeave}
489
+ onDrop={dragOverlay.onDrop}
558
490
  >
559
491
  <StudioHeader
560
492
  captureFrameHref={frameCapture.captureFrameHref}
@@ -620,6 +552,10 @@ export function StudioApp() {
620
552
  {domEditSession.agentModalOpen && domEditSession.domEditSelection && (
621
553
  <AskAgentModal
622
554
  selectionLabel={domEditSession.domEditSelection.label}
555
+ contextPreview={buildAgentContextPreview(
556
+ domEditSession.domEditSelection,
557
+ activeCompPath,
558
+ )}
623
559
  anchorPoint={domEditSession.agentModalAnchorPoint}
624
560
  onSubmit={domEditSession.handleAgentModalSubmit}
625
561
  onClose={() => {
@@ -630,7 +566,7 @@ export function StudioApp() {
630
566
  />
631
567
  )}
632
568
 
633
- {globalDragOver && <StudioGlobalDragOverlay />}
569
+ {dragOverlay.active && <StudioGlobalDragOverlay />}
634
570
 
635
571
  {appToast && (
636
572
  <div
@@ -26,11 +26,13 @@ function getAgentModalPositionStyle(
26
26
 
27
27
  export function AskAgentModal({
28
28
  selectionLabel,
29
+ contextPreview,
29
30
  anchorPoint = null,
30
31
  onSubmit,
31
32
  onClose,
32
33
  }: {
33
34
  selectionLabel: string;
35
+ contextPreview?: string;
34
36
  anchorPoint?: AgentModalAnchorPoint | null;
35
37
  onSubmit: (instruction: string) => void;
36
38
  onClose: () => void;
@@ -66,7 +68,7 @@ export function AskAgentModal({
66
68
  >
67
69
  <div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800/60">
68
70
  <div>
69
- <h3 className="text-sm font-medium text-neutral-200">Ask agent</h3>
71
+ <h3 className="text-sm font-medium text-neutral-200">Copy prompt to AI agent</h3>
70
72
  <p className="text-xs text-neutral-500 mt-0.5">
71
73
  {selectionLabel.length > 50 ? `${selectionLabel.slice(0, 49)}…` : selectionLabel}
72
74
  </p>
@@ -89,7 +91,7 @@ export function AskAgentModal({
89
91
  </svg>
90
92
  </button>
91
93
  </div>
92
- <div className="px-5 py-4">
94
+ <div className="px-5 py-4 space-y-3">
93
95
  <textarea
94
96
  ref={inputRef}
95
97
  className="w-full h-24 px-3 py-2 rounded-lg border border-neutral-800 bg-neutral-900/60 text-sm text-neutral-200 placeholder-neutral-600 resize-none focus:outline-none focus:border-studio-accent/60 focus:ring-1 focus:ring-studio-accent/30"
@@ -101,6 +103,16 @@ export function AskAgentModal({
101
103
  if (e.key === "Escape") onClose();
102
104
  }}
103
105
  />
106
+ {contextPreview && (
107
+ <details className="group">
108
+ <summary className="text-[11px] text-neutral-500 cursor-pointer select-none hover:text-neutral-400">
109
+ Context included in prompt
110
+ </summary>
111
+ <pre className="mt-2 max-h-40 overflow-auto rounded-lg bg-neutral-900/80 px-3 py-2 text-[11px] leading-relaxed text-neutral-500 whitespace-pre-wrap break-words border border-neutral-800/50">
112
+ {contextPreview}
113
+ </pre>
114
+ </details>
115
+ )}
104
116
  </div>
105
117
  <div className="flex items-center justify-between px-5 py-3 border-t border-neutral-800/60">
106
118
  <span className="text-[11px] text-neutral-600">
@@ -34,6 +34,7 @@ export interface StudioRightPanelProps {
34
34
  onCloseBlockParams?: () => void;
35
35
  }
36
36
 
37
+ // fallow-ignore-next-line complexity
37
38
  export function StudioRightPanel({
38
39
  selectedStudioMotion,
39
40
  designPanelActive,
@@ -8,23 +8,49 @@ import {
8
8
  EASE_LABELS,
9
9
  METHOD_LABELS,
10
10
  METHOD_TOOLTIPS,
11
+ PERCENT_PROPS,
11
12
  PROP_LABELS,
12
13
  PROP_TOOLTIPS,
13
14
  PROP_UNITS,
14
15
  } from "./gsapAnimationConstants";
16
+ import { buildTweenSummary } from "./gsapAnimationHelpers";
15
17
  import { EaseCurveSection } from "./EaseCurveSection";
18
+ const BOOLEAN_PROPS = new Set(["visibility"]);
16
19
 
17
- const PERCENT_PROPS = new Set(["opacity", "autoAlpha"]);
18
20
  function isPercentProp(prop: string): boolean {
19
21
  return PERCENT_PROPS.has(prop);
20
22
  }
21
23
 
22
24
  function displayValue(prop: string, val: number | string): string {
23
- return isPercentProp(prop) ? String(Math.round(Number(val) * 100)) : String(val);
25
+ if (isPercentProp(prop)) return String(Math.round(Math.max(0, Math.min(1, Number(val))) * 100));
26
+ return String(val);
24
27
  }
25
28
 
26
29
  function adjustedValue(prop: string, raw: string): string {
27
- return isPercentProp(prop) ? String(Number(raw) / 100) : raw;
30
+ if (isPercentProp(prop)) return String(Math.max(0, Math.min(1, Number(raw) / 100)));
31
+ return raw;
32
+ }
33
+
34
+ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }) {
35
+ return (
36
+ <button
37
+ type="button"
38
+ onClick={onClick}
39
+ className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
40
+ title={title}
41
+ >
42
+ <svg
43
+ width="12"
44
+ height="12"
45
+ viewBox="0 0 12 12"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ strokeWidth="1.5"
49
+ >
50
+ <path d="M3 3l6 6M9 3l-6 6" />
51
+ </svg>
52
+ </button>
53
+ );
28
54
  }
29
55
 
30
56
  function PropertyRow({
@@ -40,6 +66,30 @@ function PropertyRow({
40
66
  onRemove: () => void;
41
67
  removeTitle: string;
42
68
  }) {
69
+ if (BOOLEAN_PROPS.has(prop)) {
70
+ const isVisible = val === "visible" || val === 1;
71
+ return (
72
+ <div className="flex items-center gap-1">
73
+ <div className="min-w-0 flex-1 flex items-center gap-2 px-2 py-1 rounded-lg bg-neutral-900 border border-neutral-800">
74
+ <span className="flex-1 text-[11px] font-medium text-neutral-500">
75
+ {PROP_LABELS[prop] ?? prop}
76
+ </span>
77
+ <button
78
+ type="button"
79
+ onClick={() => onCommit(isVisible ? "hidden" : "visible")}
80
+ className={`flex-shrink-0 w-7 h-4 rounded-full transition-colors relative ${isVisible ? "bg-emerald-500/30" : "bg-neutral-700"}`}
81
+ title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
82
+ >
83
+ <span
84
+ className={`absolute top-0.5 h-3 w-3 rounded-full transition-transform ${isVisible ? "bg-emerald-400 translate-x-3.5" : "bg-neutral-500 translate-x-0.5"}`}
85
+ />
86
+ </button>
87
+ </div>
88
+ <RemoveButton onClick={onRemove} title={removeTitle} />
89
+ </div>
90
+ );
91
+ }
92
+
43
93
  return (
44
94
  <div className="flex items-center gap-1">
45
95
  <div className="min-w-0 flex-1">
@@ -53,23 +103,7 @@ function PropertyRow({
53
103
  onCommit={(raw) => onCommit(adjustedValue(prop, raw))}
54
104
  />
55
105
  </div>
56
- <button
57
- type="button"
58
- onClick={onRemove}
59
- className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
60
- title={removeTitle}
61
- >
62
- <svg
63
- width="12"
64
- height="12"
65
- viewBox="0 0 12 12"
66
- fill="none"
67
- stroke="currentColor"
68
- strokeWidth="1.5"
69
- >
70
- <path d="M3 3l6 6M9 3l-6 6" />
71
- </svg>
72
- </button>
106
+ <RemoveButton onClick={onRemove} title={removeTitle} />
73
107
  </div>
74
108
  );
75
109
  }
@@ -124,36 +158,6 @@ function AddPropertyTrigger({
124
158
  );
125
159
  }
126
160
 
127
- // fallow-ignore-next-line complexity
128
- function buildTweenSummary(animation: GsapAnimation): string {
129
- const easeName = animation.ease ?? "none";
130
- const ease = EASE_LABELS[easeName] ?? easeName;
131
- const props = Object.entries(animation.properties);
132
- const target = animation.targetSelector;
133
- const dur = animation.duration ?? 0;
134
- const pos = animation.position;
135
- const propDescs = props.map(([p, v]) => {
136
- const label = (PROP_LABELS[p] ?? p).toLowerCase();
137
- const unit = PROP_UNITS[p] ?? "";
138
- return `${label} to ${v}${unit}`;
139
- });
140
- const propText = propDescs.length > 0 ? propDescs.join(", ") : "no properties yet";
141
- if (animation.method === "set") return `At ${pos}s, instantly set ${target}'s ${propText}.`;
142
- if (animation.method === "from")
143
- return `Starting at ${pos}s, over ${dur}s, ${target} enters from ${propText} using a ${ease.toLowerCase()} curve.`;
144
- if (animation.method === "fromTo") {
145
- const fromProps = Object.entries(animation.fromProperties ?? {});
146
- const fromDescs = fromProps.map(([p, v]) => {
147
- const label = (PROP_LABELS[p] ?? p).toLowerCase();
148
- const unit = PROP_UNITS[p] ?? "";
149
- return `${label} ${v}${unit}`;
150
- });
151
- const fromText = fromDescs.length > 0 ? fromDescs.join(", ") : "—";
152
- return `Starting at ${pos}s, over ${dur}s, ${target} animates from [${fromText}] to [${propText}] using a ${ease.toLowerCase()} curve.`;
153
- }
154
- return `Starting at ${pos}s, over ${dur}s, animate ${target}'s ${propText} using a ${ease.toLowerCase()} curve.`;
155
- }
156
-
157
161
  function parseNumericOrString(raw: string): number | string {
158
162
  const num = Number(raw);
159
163
  return Number.isFinite(num) ? num : raw;
@@ -201,8 +205,11 @@ export const AnimationCard = memo(function AnimationCard({
201
205
  [animation.properties],
202
206
  );
203
207
  const availableProps = useMemo(
204
- () => SUPPORTED_PROPS.filter((p) => !usedProps.has(p)),
205
- [usedProps],
208
+ () =>
209
+ SUPPORTED_PROPS.filter(
210
+ (p) => !usedProps.has(p) && (animation.method === "set" || !BOOLEAN_PROPS.has(p)),
211
+ ),
212
+ [usedProps, animation.method],
206
213
  );
207
214
 
208
215
  const usedFromProps = useMemo(
@@ -210,7 +217,7 @@ export const AnimationCard = memo(function AnimationCard({
210
217
  [animation.fromProperties],
211
218
  );
212
219
  const availableFromProps = useMemo(
213
- () => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p)),
220
+ () => SUPPORTED_PROPS.filter((p) => !usedFromProps.has(p) && !BOOLEAN_PROPS.has(p)),
214
221
  [usedFromProps],
215
222
  );
216
223
 
@@ -118,11 +118,14 @@ export function EaseCurveSection({
118
118
  {progress !== null ? "Playing…" : "Preview"}
119
119
  </button>
120
120
  </div>
121
- <div className="overflow-hidden rounded pt-[72px] -mt-[72px]">
121
+ <div
122
+ className="overflow-hidden rounded pt-[72px] -mt-[72px]"
123
+ style={{ aspectRatio: `${w}/${h}` }}
124
+ >
122
125
  <svg
123
126
  ref={svgRef}
124
127
  width="100%"
125
- height={h}
128
+ height="100%"
126
129
  viewBox={`0 0 ${w} ${h}`}
127
130
  preserveAspectRatio="none"
128
131
  style={{ overflow: "visible" }}
@@ -135,6 +135,7 @@ function TimingSection({
135
135
  /* PropertyPanel */
136
136
  /* ------------------------------------------------------------------ */
137
137
 
138
+ // fallow-ignore-next-line complexity
138
139
  export const PropertyPanel = memo(function PropertyPanel({
139
140
  projectId,
140
141
  projectDir,
@@ -229,6 +230,7 @@ export const PropertyPanel = memo(function PropertyPanel({
229
230
  });
230
231
  };
231
232
 
233
+ // fallow-ignore-next-line complexity
232
234
  const commitManualSize = (axis: "width" | "height", nextValue: string) => {
233
235
  const parsed = parsePxMetricValue(nextValue);
234
236
  if (parsed == null || parsed <= 0) return;
@@ -281,7 +283,7 @@ export const PropertyPanel = memo(function PropertyPanel({
281
283
  className="inline-flex h-8 items-center justify-center gap-2 rounded-xl border border-neutral-700 bg-neutral-950 px-3.5 text-[11px] font-medium text-neutral-100 transition-colors hover:border-studio-accent/40 hover:text-studio-accent"
282
284
  >
283
285
  <MessageSquare size={15} />
284
- <span>{copiedAgentPrompt ? "Prompt copied" : "Ask agent"}</span>
286
+ <span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
285
287
  </button>
286
288
  </div>
287
289
  </div>
@@ -95,3 +95,17 @@ export function buildElementAgentPrompt({
95
95
 
96
96
  return lines.join("\n");
97
97
  }
98
+
99
+ export function buildAgentContextPreview(
100
+ selection: DomEditSelection,
101
+ activeCompPath: string | null,
102
+ ): string {
103
+ return [
104
+ `Composition: ${selection.compositionPath}`,
105
+ `Source: ${selection.sourceFile || activeCompPath || "index.html"}`,
106
+ `Selector: ${selection.selector ?? "(none)"} Tag: <${selection.tagName}>`,
107
+ selection.textContent ? `Text: ${selection.textContent}` : "",
108
+ ]
109
+ .filter(Boolean)
110
+ .join("\n");
111
+ }