@hyperframes/studio 0.6.86 → 0.6.87

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 (87) hide show
  1. package/dist/assets/index-BA19FAPN.js +143 -0
  2. package/dist/assets/index-CGlIm_-E.css +1 -0
  3. package/dist/index.html +2 -2
  4. package/package.json +4 -4
  5. package/src/App.tsx +159 -6
  6. package/src/components/StudioHeader.tsx +20 -7
  7. package/src/components/StudioPreviewArea.tsx +6 -1
  8. package/src/components/StudioRightPanel.tsx +13 -0
  9. package/src/components/StudioToast.tsx +47 -7
  10. package/src/components/TimelineToolbar.tsx +12 -122
  11. package/src/components/editor/AnimationCard.tsx +64 -10
  12. package/src/components/editor/ArcPathControls.tsx +131 -0
  13. package/src/components/editor/BorderRadiusEditor.tsx +209 -0
  14. package/src/components/editor/DomEditOverlay.tsx +70 -11
  15. package/src/components/editor/DopesheetStrip.tsx +141 -0
  16. package/src/components/editor/EaseCurveSection.tsx +82 -7
  17. package/src/components/editor/GestureTrailOverlay.tsx +132 -0
  18. package/src/components/editor/GsapAnimationSection.tsx +14 -1
  19. package/src/components/editor/KeyframeDiamond.tsx +27 -12
  20. package/src/components/editor/LayersPanel.tsx +14 -12
  21. package/src/components/editor/MotionPathOverlay.tsx +146 -0
  22. package/src/components/editor/PropertyPanel.tsx +196 -66
  23. package/src/components/editor/SourceEditor.tsx +0 -1
  24. package/src/components/editor/StaggerControls.tsx +61 -0
  25. package/src/components/editor/domEditOverlayGeometry.test.ts +13 -0
  26. package/src/components/editor/domEditOverlayGeometry.ts +2 -1
  27. package/src/components/editor/domEditing.test.ts +43 -0
  28. package/src/components/editor/domEditing.ts +2 -0
  29. package/src/components/editor/domEditingElement.ts +25 -2
  30. package/src/components/editor/domEditingLayers.test.ts +78 -0
  31. package/src/components/editor/domEditingLayers.ts +33 -13
  32. package/src/components/editor/domEditingTypes.ts +1 -0
  33. package/src/components/editor/manualEditingAvailability.ts +1 -1
  34. package/src/components/editor/manualEdits.ts +3 -0
  35. package/src/components/editor/manualEditsDom.ts +23 -5
  36. package/src/components/editor/manualOffsetDrag.ts +59 -0
  37. package/src/components/editor/panelTokens.ts +10 -0
  38. package/src/components/editor/propertyPanelColor.tsx +2 -2
  39. package/src/components/editor/propertyPanelFill.tsx +1 -1
  40. package/src/components/editor/propertyPanelHelpers.ts +18 -2
  41. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  42. package/src/components/editor/propertyPanelPrimitives.tsx +38 -25
  43. package/src/components/editor/propertyPanelSections.tsx +4 -6
  44. package/src/components/editor/propertyPanelStyleSections.tsx +30 -8
  45. package/src/components/editor/useDomEditOverlayRects.ts +46 -2
  46. package/src/components/renders/RenderQueue.tsx +121 -100
  47. package/src/components/renders/RenderQueueItem.tsx +13 -13
  48. package/src/contexts/DomEditContext.tsx +12 -0
  49. package/src/contexts/FileManagerContext.tsx +3 -0
  50. package/src/contexts/StudioContext.tsx +0 -4
  51. package/src/hooks/gsapKeyframeCommit.ts +92 -0
  52. package/src/hooks/gsapRuntimeBridge.ts +147 -85
  53. package/src/hooks/gsapRuntimeKeyframes.ts +75 -24
  54. package/src/hooks/gsapRuntimePreview.ts +19 -0
  55. package/src/hooks/useAppHotkeys.ts +18 -0
  56. package/src/hooks/useAskAgentModal.ts +2 -4
  57. package/src/hooks/useDomEditCommits.ts +11 -17
  58. package/src/hooks/useDomEditSession.ts +47 -4
  59. package/src/hooks/useEnableKeyframes.ts +171 -0
  60. package/src/hooks/useFileManager.ts +7 -0
  61. package/src/hooks/useGestureRecording.ts +340 -0
  62. package/src/hooks/useGsapScriptCommits.ts +171 -35
  63. package/src/hooks/useGsapSelectionHandlers.ts +27 -8
  64. package/src/hooks/useGsapTweenCache.ts +169 -11
  65. package/src/hooks/useKeyframeKeyboard.ts +103 -0
  66. package/src/hooks/useStudioContextValue.ts +5 -4
  67. package/src/hooks/useStudioUrlState.ts +1 -2
  68. package/src/hooks/useTimelineEditing.ts +50 -3
  69. package/src/hooks/useToast.ts +6 -1
  70. package/src/player/components/ShortcutsPanel.tsx +40 -0
  71. package/src/player/components/TimelineClipDiamonds.tsx +3 -3
  72. package/src/player/components/TimelinePropertyRows.tsx +120 -0
  73. package/src/player/lib/timelineDOM.test.ts +55 -0
  74. package/src/player/lib/timelineDOM.ts +13 -0
  75. package/src/player/lib/timelineIframeHelpers.test.ts +51 -0
  76. package/src/player/lib/timelineIframeHelpers.ts +1 -0
  77. package/src/player/store/playerStore.ts +43 -0
  78. package/src/utils/audioBeatDetection.ts +58 -0
  79. package/src/utils/globalTimeCompiler.test.ts +169 -0
  80. package/src/utils/globalTimeCompiler.ts +77 -0
  81. package/src/utils/gsapSoftReload.ts +30 -10
  82. package/src/utils/keyframeSnapping.test.ts +74 -0
  83. package/src/utils/keyframeSnapping.ts +63 -0
  84. package/src/utils/rdpSimplify.ts +183 -0
  85. package/src/utils/sourcePatcher.ts +2 -0
  86. package/dist/assets/index-BT9VHgSy.js +0 -140
  87. package/dist/assets/index-DHcptK1_.css +0 -1
@@ -60,9 +60,9 @@ export const LayersPanel = memo(function LayersPanel() {
60
60
  refreshKey,
61
61
  compositionLoading,
62
62
  timelineElements,
63
- currentTime,
64
63
  showToast,
65
64
  } = useStudioContext();
65
+ const currentTime = usePlayerStore((s) => s.currentTime);
66
66
  const {
67
67
  domEditSelection,
68
68
  applyDomSelection,
@@ -239,9 +239,9 @@ export const LayersPanel = memo(function LayersPanel() {
239
239
 
240
240
  if (layers.length === 0) {
241
241
  return (
242
- <div className="flex h-full flex-col items-center justify-center bg-neutral-900 px-6 text-center">
243
- <Layers size={18} className="mb-3 text-neutral-600" />
244
- <p className="text-sm font-medium text-neutral-200">No layers</p>
242
+ <div className="flex h-full flex-col items-center justify-center bg-panel-bg px-6 text-center">
243
+ <Layers size={18} className="mb-3 text-panel-text-5" />
244
+ <p className="text-sm font-medium text-panel-text-1">No layers</p>
245
245
  <p className="mt-1 text-xs text-neutral-500">Load a composition to see its element tree</p>
246
246
  </div>
247
247
  );
@@ -249,10 +249,10 @@ export const LayersPanel = memo(function LayersPanel() {
249
249
 
250
250
  return (
251
251
  <div
252
- className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900"
252
+ className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg"
253
253
  onPointerLeave={() => handleLayerHover(null)}
254
254
  >
255
- <div className="border-b border-white/10 px-3 py-2 text-[11px] text-neutral-500">
255
+ <div className="border-b border-panel-border px-3 py-2 text-[11px] text-panel-text-3">
256
256
  {layers.length} layer{layers.length === 1 ? "" : "s"}
257
257
  </div>
258
258
  <div
@@ -289,8 +289,8 @@ export const LayersPanel = memo(function LayersPanel() {
289
289
  isDragged
290
290
  ? "opacity-40"
291
291
  : selected
292
- ? "bg-studio-accent/14 text-studio-accent"
293
- : "text-neutral-300 hover:bg-white/[0.04] hover:text-neutral-100"
292
+ ? "bg-panel-accent/14 text-panel-accent"
293
+ : "text-panel-text-2 hover:bg-panel-hover/40 hover:text-panel-text-1"
294
294
  } ${dragKey ? "cursor-grabbing" : draggable ? "cursor-pointer" : "cursor-not-allowed opacity-50"}`}
295
295
  style={{ paddingLeft: 8 + layer.depth * 16 }}
296
296
  >
@@ -316,17 +316,19 @@ export const LayersPanel = memo(function LayersPanel() {
316
316
  <span
317
317
  className={`flex h-5 w-5 flex-shrink-0 items-center justify-center rounded text-[8px] font-bold uppercase ${
318
318
  selected
319
- ? "bg-studio-accent/18 text-studio-accent"
319
+ ? "bg-panel-accent/18 text-panel-accent"
320
320
  : isCompHost
321
- ? "bg-blue-900/40 text-blue-400"
322
- : "bg-neutral-800 text-neutral-500"
321
+ ? "bg-panel-accent/40 text-panel-accent"
322
+ : "bg-panel-hover text-panel-text-4"
323
323
  }`}
324
324
  >
325
325
  {getTagBadge(layer.tagName)}
326
326
  </span>
327
327
  <span className="min-w-0 flex-1 truncate text-[11px]">{layer.label}</span>
328
328
  {hasChildren && (
329
- <span className="text-[9px] tabular-nums text-neutral-600">{layer.childCount}</span>
329
+ <span className="text-[9px] tabular-nums text-panel-text-5">
330
+ {layer.childCount}
331
+ </span>
330
332
  )}
331
333
  </div>
332
334
  );
@@ -0,0 +1,146 @@
1
+ import { memo, useMemo, type RefObject } from "react";
2
+ import type { ArcPathConfig } from "@hyperframes/core/gsap-parser";
3
+
4
+ interface MotionPathOverlayProps {
5
+ iframeRef: RefObject<HTMLIFrameElement | null>;
6
+ arcPath: ArcPathConfig | null;
7
+ waypoints: Array<{ x: number; y: number }> | null;
8
+ elementBaseRect: { left: number; top: number; scaleX: number; scaleY: number } | null;
9
+ }
10
+
11
+ function buildSvgPath(
12
+ waypoints: Array<{ x: number; y: number }>,
13
+ segments: ArcPathConfig["segments"],
14
+ base: { left: number; top: number; scaleX: number; scaleY: number },
15
+ ): string {
16
+ if (waypoints.length < 2) return "";
17
+
18
+ const toPixel = (wp: { x: number; y: number }) => ({
19
+ x: base.left + wp.x * base.scaleX,
20
+ y: base.top + wp.y * base.scaleY,
21
+ });
22
+
23
+ const first = toPixel(waypoints[0]!);
24
+ const parts = [`M ${first.x} ${first.y}`];
25
+
26
+ for (let i = 0; i < segments.length && i < waypoints.length - 1; i++) {
27
+ const seg = segments[i]!;
28
+ const end = toPixel(waypoints[i + 1]!);
29
+
30
+ if (seg.cp1 && seg.cp2) {
31
+ const c1 = toPixel(seg.cp1);
32
+ const c2 = toPixel(seg.cp2);
33
+ parts.push(`C ${c1.x} ${c1.y} ${c2.x} ${c2.y} ${end.x} ${end.y}`);
34
+ } else {
35
+ const start = toPixel(waypoints[i]!);
36
+ const dx = end.x - start.x;
37
+ const dy = end.y - start.y;
38
+ const c = seg.curviness ?? 1;
39
+ const offset = c * Math.abs(dx) * 0.25;
40
+ const c1x = start.x + dx * 0.33;
41
+ const c1y = start.y + dy * 0.33 - offset;
42
+ const c2x = start.x + dx * 0.66;
43
+ const c2y = start.y + dy * 0.66 - offset;
44
+ parts.push(`C ${c1x} ${c1y} ${c2x} ${c2y} ${end.x} ${end.y}`);
45
+ }
46
+ }
47
+
48
+ return parts.join(" ");
49
+ }
50
+
51
+ export const MotionPathOverlay = memo(function MotionPathOverlay({
52
+ arcPath,
53
+ waypoints,
54
+ elementBaseRect,
55
+ }: MotionPathOverlayProps) {
56
+ const pathD = useMemo(() => {
57
+ if (!arcPath?.enabled || !waypoints || waypoints.length < 2 || !elementBaseRect) return "";
58
+ return buildSvgPath(waypoints, arcPath.segments, elementBaseRect);
59
+ }, [arcPath, waypoints, elementBaseRect]);
60
+
61
+ const anchorPoints = useMemo(() => {
62
+ if (!waypoints || !elementBaseRect) return [];
63
+ return waypoints.map((wp) => ({
64
+ x: elementBaseRect.left + wp.x * elementBaseRect.scaleX,
65
+ y: elementBaseRect.top + wp.y * elementBaseRect.scaleY,
66
+ }));
67
+ }, [waypoints, elementBaseRect]);
68
+
69
+ const controlPoints = useMemo(() => {
70
+ if (!arcPath?.enabled || !elementBaseRect) return [];
71
+ const points: Array<{
72
+ segIndex: number;
73
+ type: "cp1" | "cp2";
74
+ x: number;
75
+ y: number;
76
+ anchorX: number;
77
+ anchorY: number;
78
+ }> = [];
79
+ for (let i = 0; i < arcPath.segments.length; i++) {
80
+ const seg = arcPath.segments[i]!;
81
+ if (seg.cp1 && seg.cp2 && waypoints) {
82
+ const anchor1 = waypoints[i]!;
83
+ const anchor2 = waypoints[i + 1]!;
84
+ points.push({
85
+ segIndex: i,
86
+ type: "cp1",
87
+ x: elementBaseRect.left + seg.cp1.x * elementBaseRect.scaleX,
88
+ y: elementBaseRect.top + seg.cp1.y * elementBaseRect.scaleY,
89
+ anchorX: elementBaseRect.left + anchor1.x * elementBaseRect.scaleX,
90
+ anchorY: elementBaseRect.top + anchor1.y * elementBaseRect.scaleY,
91
+ });
92
+ points.push({
93
+ segIndex: i,
94
+ type: "cp2",
95
+ x: elementBaseRect.left + seg.cp2.x * elementBaseRect.scaleX,
96
+ y: elementBaseRect.top + seg.cp2.y * elementBaseRect.scaleY,
97
+ anchorX: elementBaseRect.left + anchor2.x * elementBaseRect.scaleX,
98
+ anchorY: elementBaseRect.top + anchor2.y * elementBaseRect.scaleY,
99
+ });
100
+ }
101
+ }
102
+ return points;
103
+ }, [arcPath, waypoints, elementBaseRect]);
104
+
105
+ if (!pathD) return null;
106
+
107
+ return (
108
+ <svg className="absolute inset-0 pointer-events-none z-20 overflow-visible">
109
+ <path d={pathD} fill="none" stroke="rgba(45, 212, 191, 0.4)" strokeWidth={2} />
110
+
111
+ {controlPoints.map((cp) => (
112
+ <g key={`${cp.segIndex}-${cp.type}`}>
113
+ <line
114
+ x1={cp.anchorX}
115
+ y1={cp.anchorY}
116
+ x2={cp.x}
117
+ y2={cp.y}
118
+ stroke="rgba(167, 139, 250, 0.3)"
119
+ strokeWidth={1}
120
+ strokeDasharray="3 2"
121
+ />
122
+ <circle
123
+ cx={cp.x}
124
+ cy={cp.y}
125
+ r={4}
126
+ fill="#a78bfa"
127
+ className="pointer-events-auto cursor-grab"
128
+ />
129
+ </g>
130
+ ))}
131
+
132
+ {anchorPoints.map((pt, i) => (
133
+ <circle
134
+ key={i}
135
+ cx={pt.x}
136
+ cy={pt.y}
137
+ r={5}
138
+ fill="#3CE6AC"
139
+ stroke="rgba(255,255,255,0.5)"
140
+ strokeWidth={1}
141
+ className="pointer-events-auto cursor-pointer"
142
+ />
143
+ ))}
144
+ </svg>
145
+ );
146
+ });
@@ -1,10 +1,10 @@
1
- import { memo } from "react";
2
- import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons";
1
+ import { memo, useRef, useState } from "react";
2
+ import { Eye, Layers, Move, X } from "../../icons/SystemIcons";
3
+ import { useStudioContext } from "../../contexts/StudioContext";
3
4
  import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits";
4
5
  import {
5
6
  EMPTY_STYLES,
6
7
  formatPxMetricValue,
7
- LABEL,
8
8
  parsePxMetricValue,
9
9
  RESPONSIVE_GRID,
10
10
  } from "./propertyPanelHelpers";
@@ -16,7 +16,7 @@ import { KeyframeNavigation } from "./KeyframeNavigation";
16
16
  import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
17
17
  import { usePlayerStore } from "../../player";
18
18
  import { TimingSection } from "./propertyPanelTimingSection";
19
- import { computeFitToChildrenSize, type PropertyPanelProps } from "./propertyPanelHelpers";
19
+ import { type PropertyPanelProps } from "./propertyPanelHelpers";
20
20
 
21
21
  // Re-export helpers that external consumers import from this module
22
22
  export {
@@ -41,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({
41
41
  assets,
42
42
  element,
43
43
  multiSelectCount = 0,
44
- copiedAgentPrompt,
44
+ copiedAgentPrompt: _copiedAgentPrompt,
45
45
  onClearSelection,
46
46
  onSetStyle,
47
47
  onSetAttribute,
@@ -53,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({
53
53
  onSetTextFieldStyle,
54
54
  onAddTextField,
55
55
  onRemoveTextField,
56
- onAskAgent,
56
+ onAskAgent: _onAskAgent,
57
57
  onImportAssets,
58
58
  fontAssets = [],
59
59
  onImportFonts,
@@ -70,13 +70,22 @@ export const PropertyPanel = memo(function PropertyPanel({
70
70
  onAddGsapFromProperty,
71
71
  onRemoveGsapFromProperty,
72
72
  onAddGsapAnimation,
73
+ onSetArcPath,
74
+ onUpdateArcSegment,
73
75
  onAddKeyframe,
74
76
  onRemoveKeyframe,
75
77
  onConvertToKeyframes,
76
78
  onCommitAnimatedProperty,
77
79
  onSeekToTime,
80
+ recordingState,
81
+ recordingDuration,
82
+ onToggleRecording,
78
83
  }: PropertyPanelProps) {
79
84
  const styles = element?.computedStyles ?? EMPTY_STYLES;
85
+ const { showToast } = useStudioContext();
86
+ const [clipboardCopied, setClipboardCopied] = useState(false);
87
+ const clipboardTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
88
+ const currentTime = usePlayerStore((s) => s.currentTime);
80
89
 
81
90
  if (!element) {
82
91
  return (
@@ -170,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({
170
179
  onSetManualRotation(element, { angle: parsed });
171
180
  };
172
181
 
173
- // Keyframe navigation state
174
182
  const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0;
175
183
  const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0;
176
- const currentTime = usePlayerStore((s) => s.currentTime);
177
184
  const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0;
178
185
 
179
186
  const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null;
@@ -217,6 +224,34 @@ export const PropertyPanel = memo(function PropertyPanel({
217
224
  }
218
225
  })();
219
226
 
227
+ const gsapBorderRadius: { tl: number; tr: number; br: number; bl: number } | null = (() => {
228
+ if (!gsapRuntimeValues || !("borderRadius" in gsapRuntimeValues)) {
229
+ const hasBRProp = gsapAnimations.some(
230
+ (a) =>
231
+ "borderRadius" in a.properties ||
232
+ a.keyframes?.keyframes.some((kf) => "borderRadius" in kf.properties),
233
+ );
234
+ if (!hasBRProp) return null;
235
+ }
236
+ const iframe = previewIframeRef?.current;
237
+ const selector = element.id ? `#${element.id}` : element.selector;
238
+ if (!iframe?.contentDocument || !selector) return null;
239
+ try {
240
+ const el = iframe.contentDocument.querySelector(selector);
241
+ if (!el) return null;
242
+ const cs = iframe.contentWindow!.getComputedStyle(el);
243
+ const parse = (v: string) => Number.parseFloat(v) || 0;
244
+ return {
245
+ tl: parse(cs.borderTopLeftRadius),
246
+ tr: parse(cs.borderTopRightRadius),
247
+ br: parse(cs.borderBottomRightRadius),
248
+ bl: parse(cs.borderBottomLeftRadius),
249
+ };
250
+ } catch {
251
+ return null;
252
+ }
253
+ })();
254
+
220
255
  const displayX = gsapRuntimeValues?.x ?? manualOffset.x;
221
256
  const displayY = gsapRuntimeValues?.y ?? manualOffset.y;
222
257
  const displayW = gsapRuntimeValues?.width ?? resolvedWidth;
@@ -224,34 +259,100 @@ export const PropertyPanel = memo(function PropertyPanel({
224
259
  const displayR = gsapRuntimeValues?.rotation ?? manualRotation.angle;
225
260
 
226
261
  return (
227
- <div className="flex h-full min-h-0 flex-col overflow-hidden bg-neutral-900 text-neutral-100">
228
- <div className="border-b border-neutral-800 px-4 py-5">
229
- <div className="flex items-start justify-between gap-4">
262
+ <div className="flex h-full min-h-0 flex-col overflow-hidden bg-panel-bg text-panel-text-1">
263
+ <div className="px-4 py-3">
264
+ <div className="flex items-center justify-between gap-4">
230
265
  <div className="min-w-0">
231
- <div className={LABEL}>Document</div>
232
- <div className="mt-3 truncate text-[12px] font-semibold text-neutral-100">
266
+ <div className="truncate text-[13px] font-semibold text-neutral-100">
233
267
  {element.label}
234
268
  </div>
235
- <div className="mt-1 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
269
+ <div className="mt-0.5 truncate text-[11px] text-neutral-500">{sourceLabel}</div>
270
+ </div>
271
+ <div className="flex items-center gap-1">
272
+ <button
273
+ type="button"
274
+ onClick={() => {
275
+ const file = element.sourceFile ?? "index.html";
276
+ let lineNum: number | null = null;
277
+ try {
278
+ const src =
279
+ previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
280
+ if (src && element.id) {
281
+ const idx = src.indexOf(`id="${element.id}"`);
282
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
283
+ }
284
+ if (!lineNum && element.selector) {
285
+ const tag = element.tagName.toLowerCase();
286
+ const cls = element.selector.startsWith(".")
287
+ ? element.selector.slice(1).split(".")[0]
288
+ : null;
289
+ const search = cls ? `class="${cls}` : `<${tag}`;
290
+ const idx = src.indexOf(search);
291
+ if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
292
+ }
293
+ } catch {}
294
+ const fileLoc = lineNum ? `${file}:${lineNum}` : file;
295
+ const lines = [
296
+ `Element: ${element.label} (${sourceLabel})`,
297
+ `File: ${fileLoc}`,
298
+ `Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
299
+ `Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
300
+ `Tag: <${element.tagName}>`,
301
+ ];
302
+ if (
303
+ element.computedStyles["z-index"] &&
304
+ element.computedStyles["z-index"] !== "auto"
305
+ ) {
306
+ lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
307
+ }
308
+ if (gsapAnimations.length > 0) {
309
+ const anim = gsapAnimations[0];
310
+ lines.push(
311
+ `Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
312
+ );
313
+ const props = Object.entries(anim.properties)
314
+ .map(([k, v]) => `${k}: ${v}`)
315
+ .join(", ");
316
+ if (props) lines.push(`Properties: ${props}`);
317
+ }
318
+ const text = lines.join("\n");
319
+ void navigator.clipboard.writeText(text);
320
+ showToast(
321
+ `Copied element info for ${element.label} — paste into any AI agent`,
322
+ "info",
323
+ );
324
+ setClipboardCopied(true);
325
+ clearTimeout(clipboardTimerRef.current);
326
+ clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
327
+ }}
328
+ className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
329
+ clipboardCopied
330
+ ? "text-studio-accent"
331
+ : "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
332
+ }`}
333
+ title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
334
+ >
335
+ <svg
336
+ width="13"
337
+ height="13"
338
+ viewBox="0 0 16 16"
339
+ fill="none"
340
+ stroke="currentColor"
341
+ strokeWidth="1.5"
342
+ >
343
+ <rect x="5" y="5" width="9" height="9" rx="1.5" />
344
+ <path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
345
+ </svg>
346
+ </button>
347
+ <button
348
+ type="button"
349
+ aria-label="Clear selection"
350
+ onClick={onClearSelection}
351
+ className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
352
+ >
353
+ <X size={13} />
354
+ </button>
236
355
  </div>
237
- <button
238
- type="button"
239
- aria-label="Clear selection"
240
- onClick={onClearSelection}
241
- className="flex h-9 w-9 items-center justify-center rounded-full border border-neutral-700 bg-neutral-950 text-neutral-500 shadow-[0_1px_2px_rgba(0,0,0,0.2)] transition-colors hover:border-neutral-600 hover:text-neutral-200"
242
- >
243
- <X size={13} />
244
- </button>
245
- </div>
246
- <div className="mt-4 flex min-w-0 flex-wrap items-center gap-2">
247
- <button
248
- type="button"
249
- onClick={onAskAgent}
250
- 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"
251
- >
252
- <MessageSquare size={15} />
253
- <span>{copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}</span>
254
- </button>
255
356
  </div>
256
357
  </div>
257
358
 
@@ -384,29 +485,6 @@ export const PropertyPanel = memo(function PropertyPanel({
384
485
  />
385
486
  )}
386
487
  </div>
387
- {element.capabilities.canApplyManualSize && (
388
- <button
389
- type="button"
390
- className="flex-shrink-0 rounded p-1 text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
391
- title="Fit to children"
392
- onClick={() => {
393
- const size = computeFitToChildrenSize(element);
394
- if (size) onSetManualSize(element, size);
395
- }}
396
- >
397
- <svg
398
- width="14"
399
- height="14"
400
- viewBox="0 0 14 14"
401
- fill="none"
402
- stroke="currentColor"
403
- strokeWidth="1.2"
404
- >
405
- <rect x="2" y="2" width="10" height="10" strokeDasharray="2 1.5" rx="1" />
406
- <path d="M2 4.5h1m-1 5h1m8-5h1m-1 5h1M4.5 2v1m5-1v1M4.5 11v1m5-1v1" />
407
- </svg>
408
- </button>
409
- )}
410
488
  <div className="flex items-center gap-1">
411
489
  <div className="flex-1">
412
490
  <MetricField
@@ -467,17 +545,40 @@ export const PropertyPanel = memo(function PropertyPanel({
467
545
  />
468
546
  )}
469
547
  </div>
470
- <MetricField
471
- label="Scale"
472
- value={String(gsapRuntimeValues.scale ?? 1)}
473
- scrub
474
- onCommit={(next) => {
475
- const v = Number.parseFloat(next);
476
- if (Number.isFinite(v) && onCommitAnimatedProperty) {
477
- void onCommitAnimatedProperty(element, "scale", v);
478
- }
479
- }}
480
- />
548
+ <div className="flex items-center gap-1">
549
+ <div className="flex-1">
550
+ <MetricField
551
+ label="Scale"
552
+ value={String(gsapRuntimeValues.scale ?? 1)}
553
+ scrub
554
+ onCommit={(next) => {
555
+ const v = Number.parseFloat(next);
556
+ if (Number.isFinite(v) && onCommitAnimatedProperty) {
557
+ void onCommitAnimatedProperty(element, "scale", v);
558
+ }
559
+ }}
560
+ />
561
+ </div>
562
+ {STUDIO_KEYFRAMES_ENABLED && (gsapAnimId || onCommitAnimatedProperty) && (
563
+ <KeyframeNavigation
564
+ property="scale"
565
+ keyframes={gsapKeyframes}
566
+ currentPercentage={currentPct}
567
+ onSeek={(pct) => onSeekToTime?.(elStart + (pct / 100) * elDuration)}
568
+ onAddKeyframe={() => {
569
+ if (onCommitAnimatedProperty) {
570
+ void onCommitAnimatedProperty(
571
+ element,
572
+ "scale",
573
+ gsapRuntimeValues?.scale ?? 1,
574
+ );
575
+ }
576
+ }}
577
+ onRemoveKeyframe={(pct) => gsapAnimId && onRemoveKeyframe?.(gsapAnimId, pct)}
578
+ onConvertToKeyframes={() => gsapAnimId && onConvertToKeyframes?.(gsapAnimId)}
579
+ />
580
+ )}
581
+ </div>
481
582
  <MetricField
482
583
  label="RotX"
483
584
  value={`${gsapRuntimeValues.rotationX ?? 0}°`}
@@ -533,9 +634,37 @@ export const PropertyPanel = memo(function PropertyPanel({
533
634
  onAddFromProperty={onAddGsapFromProperty}
534
635
  onRemoveFromProperty={onRemoveGsapFromProperty}
535
636
  onAddAnimation={onAddGsapAnimation}
637
+ onSetArcPath={onSetArcPath}
638
+ onUpdateArcSegment={onUpdateArcSegment}
536
639
  />
537
640
  )}
538
641
 
642
+ {onToggleRecording && (
643
+ <div className="px-4 pb-3">
644
+ <button
645
+ type="button"
646
+ onMouseDown={(e) => e.preventDefault()}
647
+ onClick={onToggleRecording}
648
+ className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
649
+ recordingState === "recording"
650
+ ? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
651
+ : "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
652
+ }`}
653
+ >
654
+ <svg width="10" height="10" viewBox="0 0 10 10">
655
+ {recordingState === "recording" ? (
656
+ <rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
657
+ ) : (
658
+ <circle cx="5" cy="5" r="4.5" fill="currentColor" />
659
+ )}
660
+ </svg>
661
+ {recordingState === "recording"
662
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
663
+ : "Record gesture (R) — move pointer to capture motion"}
664
+ </button>
665
+ </div>
666
+ )}
667
+
539
668
  {showEditableSections && (
540
669
  <StyleSections
541
670
  projectId={projectId}
@@ -544,6 +673,7 @@ export const PropertyPanel = memo(function PropertyPanel({
544
673
  assets={assets}
545
674
  onSetStyle={onSetStyle}
546
675
  onImportAssets={onImportAssets}
676
+ gsapBorderRadius={gsapBorderRadius}
547
677
  />
548
678
  )}
549
679
  </div>
@@ -143,7 +143,6 @@ export const SourceEditor = memo(function SourceEditor({
143
143
  selection: { anchor: pos },
144
144
  effects: EditorView.scrollIntoView(pos, { y: "center" }),
145
145
  });
146
- view.focus();
147
146
  }, [revealOffset]);
148
147
 
149
148
  return <div ref={mountEditor} className="h-full w-full overflow-hidden" />;
@@ -0,0 +1,61 @@
1
+ import { memo, useState } from "react";
2
+ import { MetricField } from "./propertyPanelPrimitives";
3
+
4
+ export type StaggerOrder = "dom" | "reverse" | "center" | "edges" | "random";
5
+
6
+ interface StaggerControlsProps {
7
+ elementCount: number;
8
+ onApplyStagger: (offsetMs: number, order: StaggerOrder) => void;
9
+ }
10
+
11
+ const ORDER_OPTIONS: StaggerOrder[] = ["dom", "reverse", "center", "edges", "random"];
12
+ const ORDER_LABELS: Record<StaggerOrder, string> = {
13
+ dom: "DOM order",
14
+ reverse: "Reverse",
15
+ center: "Center out",
16
+ edges: "Edges in",
17
+ random: "Random",
18
+ };
19
+
20
+ export const StaggerControls = memo(function StaggerControls({
21
+ elementCount,
22
+ onApplyStagger,
23
+ }: StaggerControlsProps) {
24
+ const [offsetMs, setOffsetMs] = useState(80);
25
+ const [order, setOrder] = useState<StaggerOrder>("dom");
26
+
27
+ if (elementCount < 2) return null;
28
+
29
+ return (
30
+ <div className="flex items-center gap-2 rounded-lg border border-neutral-800 bg-neutral-900/50 px-2 py-1.5">
31
+ <span className="text-[10px] font-medium text-neutral-500">Stagger</span>
32
+ <MetricField
33
+ label="Offset"
34
+ value={String(offsetMs)}
35
+ suffix="ms"
36
+ onCommit={(raw) => {
37
+ const v = Number.parseInt(raw, 10);
38
+ if (Number.isFinite(v) && v >= 0) setOffsetMs(v);
39
+ }}
40
+ />
41
+ <select
42
+ value={order}
43
+ onChange={(e) => setOrder(e.target.value as StaggerOrder)}
44
+ className="rounded-md border border-neutral-700 bg-neutral-900 px-1.5 py-1 text-[10px] text-neutral-200 outline-none"
45
+ >
46
+ {ORDER_OPTIONS.map((o) => (
47
+ <option key={o} value={o}>
48
+ {ORDER_LABELS[o]}
49
+ </option>
50
+ ))}
51
+ </select>
52
+ <button
53
+ type="button"
54
+ onClick={() => onApplyStagger(offsetMs, order)}
55
+ className="rounded-md bg-panel-accent/10 px-2 py-1 text-[10px] font-semibold text-panel-accent transition-colors hover:bg-panel-accent/20"
56
+ >
57
+ Apply ({elementCount})
58
+ </button>
59
+ </div>
60
+ );
61
+ });
@@ -0,0 +1,13 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import { selectionCacheKey } from "./domEditOverlayGeometry";
4
+
5
+ describe("selectionCacheKey — hfId collision (R7)", () => {
6
+ it("produces distinct keys for two elements that differ only by hfId", () => {
7
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
8
+ const a = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-111" } as any);
9
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
10
+ const b = selectionCacheKey({ sourceFile: "index.html", hfId: "hf-222" } as any);
11
+ expect(a).not.toBe(b);
12
+ });
13
+ });
@@ -190,10 +190,11 @@ export function filterNestedDomEditGroupItems<T extends { element: HTMLElement }
190
190
  }
191
191
 
192
192
  export function selectionCacheKey(
193
- selection: Pick<DomEditSelection, "id" | "selector" | "selectorIndex" | "sourceFile">,
193
+ selection: Pick<DomEditSelection, "id" | "hfId" | "selector" | "selectorIndex" | "sourceFile">,
194
194
  ): string {
195
195
  return [
196
196
  selection.sourceFile ?? "",
197
+ selection.hfId ?? "",
197
198
  selection.id ?? "",
198
199
  selection.selector ?? "",
199
200
  selection.selectorIndex ?? "",