@hyperframes/studio 0.6.95 → 0.6.97

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 (50) hide show
  1. package/dist/assets/hyperframes-player-Daj5djxa.js +418 -0
  2. package/dist/assets/index-B0twsRu0.css +1 -0
  3. package/dist/assets/index-Cfye9xzo.js +251 -0
  4. package/dist/assets/{index-CAANLw9Q.js → index-HveJ0MuV.js} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/App.tsx +10 -5
  8. package/src/components/SaveQueuePausedBanner.tsx +23 -0
  9. package/src/components/StudioPreviewArea.tsx +7 -0
  10. package/src/components/StudioRightPanel.tsx +1 -38
  11. package/src/components/editor/DomEditOverlay.test.ts +169 -29
  12. package/src/components/editor/DomEditOverlay.tsx +13 -23
  13. package/src/components/editor/GestureRecordControl.tsx +98 -0
  14. package/src/components/editor/PropertyPanel.tsx +22 -38
  15. package/src/components/editor/domEditing.test.ts +84 -0
  16. package/src/components/editor/domEditingLayers.ts +19 -0
  17. package/src/components/editor/domEditingRootLayer.ts +64 -0
  18. package/src/components/editor/manualEditingAvailability.test.ts +1 -2
  19. package/src/components/editor/manualEditingAvailability.ts +0 -7
  20. package/src/contexts/DomEditContext.tsx +1 -6
  21. package/src/hooks/gsapScriptCommitHelpers.ts +128 -0
  22. package/src/hooks/useDomEditCommits.ts +97 -123
  23. package/src/hooks/useDomEditPositionPatchCommit.ts +53 -0
  24. package/src/hooks/useDomEditSession.ts +59 -65
  25. package/src/hooks/useFileManager.ts +19 -5
  26. package/src/hooks/useGsapAnimationFetchFallback.ts +19 -0
  27. package/src/hooks/useGsapInteractionFailureTelemetry.ts +25 -0
  28. package/src/hooks/useGsapScriptCommits.ts +152 -140
  29. package/src/hooks/useGsapSelectionHandlers.ts +38 -8
  30. package/src/hooks/usePreviewPersistence.ts +90 -51
  31. package/src/hooks/useSafeGsapCommitMutation.ts +66 -0
  32. package/src/hooks/useStudioContextValue.ts +3 -19
  33. package/src/player/hooks/useTimelinePlayer.ts +25 -28
  34. package/src/player/lib/playbackAdapter.test.ts +86 -1
  35. package/src/player/lib/playbackAdapter.ts +62 -0
  36. package/src/utils/domEditSaveQueue.test.ts +117 -0
  37. package/src/utils/domEditSaveQueue.ts +87 -0
  38. package/src/utils/studioHelpers.ts +1 -1
  39. package/src/utils/studioSaveDiagnostics.test.ts +127 -0
  40. package/src/utils/studioSaveDiagnostics.ts +200 -0
  41. package/src/utils/studioUrlState.test.ts +0 -1
  42. package/src/utils/studioUrlState.ts +2 -8
  43. package/dist/assets/hyperframes-player-0esDKGRk.js +0 -418
  44. package/dist/assets/index-DujOjou6.js +0 -251
  45. package/dist/assets/index-rm9tn9nH.css +0 -1
  46. package/src/components/editor/EaseCurveEditor.tsx +0 -221
  47. package/src/components/editor/MotionPanel.tsx +0 -277
  48. package/src/components/editor/MotionPanelFields.tsx +0 -185
  49. package/src/components/editor/MotionPathOverlay.tsx +0 -146
  50. package/src/components/editor/SpringEaseEditor.tsx +0 -256
@@ -1,4 +1,4 @@
1
- import{n as Qi}from"./index-DujOjou6.js";/*!
1
+ import{n as Qi}from"./index-Cfye9xzo.js";/*!
2
2
  * Copyright (c) 2026-present, Vanilagy and contributors
3
3
  *
4
4
  * This Source Code Form is subject to the terms of the Mozilla Public
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-DujOjou6.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-rm9tn9nH.css">
8
+ <script type="module" crossorigin src="/assets/index-Cfye9xzo.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-B0twsRu0.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.95",
3
+ "version": "0.6.97",
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.95",
35
- "@hyperframes/player": "0.6.95"
34
+ "@hyperframes/core": "0.6.97",
35
+ "@hyperframes/player": "0.6.97"
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.95"
49
+ "@hyperframes/producer": "0.6.97"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "react": "19",
package/src/App.tsx CHANGED
@@ -3,6 +3,7 @@ import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSid
3
3
  import { useRenderQueue } from "./components/renders/useRenderQueue";
4
4
  import { usePlayerStore } from "./player";
5
5
  import { LintModal } from "./components/LintModal";
6
+ import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
6
7
  import { useCaptionStore } from "./captions/store";
7
8
  import { useCaptionSync } from "./captions/hooks/useCaptionSync";
8
9
  import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -389,9 +390,7 @@ export function StudioApp() {
389
390
  );
390
391
 
391
392
  const {
392
- selectedStudioMotion,
393
393
  designPanelActive,
394
- motionPanelActive,
395
394
  inspectorPanelActive,
396
395
  inspectorButtonActive,
397
396
  shouldShowSelectedDomBounds,
@@ -399,7 +398,6 @@ export function StudioApp() {
399
398
  panelLayout.rightPanelTab,
400
399
  panelLayout.rightCollapsed,
401
400
  isPlaying,
402
- domEditSession.domEditSelection,
403
401
  gestureState === "recording",
404
402
  );
405
403
 
@@ -481,6 +479,13 @@ export function StudioApp() {
481
479
  onExport={() => void renderQueue.startRender()}
482
480
  />
483
481
 
482
+ {previewPersistence.domEditSaveQueuePaused && (
483
+ <SaveQueuePausedBanner
484
+ message={previewPersistence.domEditSaveQueuePaused}
485
+ onDismiss={previewPersistence.resetDomEditSaveQueueBreaker}
486
+ />
487
+ )}
488
+
484
489
  <div className="flex flex-1 min-h-0">
485
490
  <StudioLeftSidebar
486
491
  leftSidebarRef={leftSidebarRef}
@@ -510,6 +515,8 @@ export function StudioApp() {
510
515
  setCompositionLoading={setCompositionLoading}
511
516
  shouldShowSelectedDomBounds={shouldShowSelectedDomBounds}
512
517
  isGestureRecording={gestureState === "recording"}
518
+ recordingState={gestureState}
519
+ onToggleRecording={STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined}
513
520
  blockPreview={blockPreview}
514
521
  gestureOverlay={
515
522
  gestureState === "recording" && previewIframe ? (
@@ -530,9 +537,7 @@ export function StudioApp() {
530
537
 
531
538
  {!panelLayout.rightCollapsed && (
532
539
  <StudioRightPanel
533
- selectedStudioMotion={selectedStudioMotion}
534
540
  designPanelActive={designPanelActive}
535
- motionPanelActive={motionPanelActive}
536
541
  activeBlockParams={activeBlockParams}
537
542
  onCloseBlockParams={() => {
538
543
  setActiveBlockParams(null);
@@ -0,0 +1,23 @@
1
+ interface SaveQueuePausedBannerProps {
2
+ message: string;
3
+ onDismiss: () => void;
4
+ }
5
+
6
+ /** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */
7
+ export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) {
8
+ return (
9
+ <div
10
+ className="absolute left-1/2 top-14 z-[92] flex max-w-[calc(100vw-32px)] -translate-x-1/2 items-center gap-3 rounded-md border border-red-500/30 bg-red-950/85 px-4 py-2 text-[12px] font-medium text-red-100 shadow-lg shadow-black/30"
11
+ role="alert"
12
+ >
13
+ <span>{message}</span>
14
+ <button
15
+ type="button"
16
+ onClick={onDismiss}
17
+ className="rounded border border-red-300/20 px-2 py-1 text-[11px] text-red-100 transition-colors hover:bg-red-400/10"
18
+ >
19
+ Dismiss
20
+ </button>
21
+ </div>
22
+ );
23
+ }
@@ -17,6 +17,7 @@ import { useStudioContext } from "../contexts/StudioContext";
17
17
  import { useDomEditContext } from "../contexts/DomEditContext";
18
18
  import type { BlockPreviewInfo } from "./sidebar/BlocksTab";
19
19
  import { readStudioUiPreferences } from "../utils/studioUiPreferences";
20
+ import type { GestureRecordingState } from "./editor/GestureRecordControl";
20
21
 
21
22
  export interface StudioPreviewAreaProps {
22
23
  timelineToolbar: ReactNode;
@@ -59,6 +60,8 @@ export interface StudioPreviewAreaProps {
59
60
  shouldShowSelectedDomBounds: boolean;
60
61
  blockPreview?: BlockPreviewInfo | null;
61
62
  isGestureRecording?: boolean;
63
+ recordingState?: GestureRecordingState;
64
+ onToggleRecording?: () => void;
62
65
  gestureOverlay?: ReactNode;
63
66
  }
64
67
 
@@ -81,6 +84,8 @@ export function StudioPreviewArea({
81
84
  setCompositionLoading,
82
85
  shouldShowSelectedDomBounds,
83
86
  isGestureRecording,
87
+ recordingState,
88
+ onToggleRecording,
84
89
  blockPreview,
85
90
  gestureOverlay,
86
91
  }: StudioPreviewAreaProps) {
@@ -290,6 +295,8 @@ export function StudioPreviewArea({
290
295
  onRotationCommit={handleDomRotationCommit}
291
296
  gridVisible={snapPrefs.gridVisible}
292
297
  gridSpacing={snapPrefs.gridSpacing}
298
+ recordingState={recordingState}
299
+ onToggleRecording={onToggleRecording}
293
300
  />
294
301
  <SnapToolbar onSnapChange={setSnapPrefs} />
295
302
  {gestureOverlay}
@@ -1,20 +1,12 @@
1
1
  import { Tooltip } from "./ui";
2
2
  import { PropertyPanel } from "./editor/PropertyPanel";
3
- import { MotionPanel } from "./editor/MotionPanel";
4
3
  import { LayersPanel } from "./editor/LayersPanel";
5
4
  import { CaptionPropertyPanel } from "../captions/components/CaptionPropertyPanel";
6
5
  import { BlockParamsPanel } from "./editor/BlockParamsPanel";
7
6
  import { RenderQueue } from "./renders/RenderQueue";
8
7
  import type { RenderJob } from "./renders/useRenderQueue";
9
- import type { StudioGsapMotion } from "./editor/studioMotion";
10
8
  import type { BlockParam } from "@hyperframes/core/registry";
11
- import {
12
- STUDIO_INSPECTOR_PANELS_ENABLED,
13
- STUDIO_MOTION_PANEL_ENABLED,
14
- } from "./editor/manualEditingAvailability";
15
-
16
- /** Motion data without targeting metadata. */
17
- type StudioMotionData = Omit<StudioGsapMotion, "kind" | "target" | "updatedAt">;
9
+ import { STUDIO_INSPECTOR_PANELS_ENABLED } from "./editor/manualEditingAvailability";
18
10
 
19
11
  import { useStudioContext } from "../contexts/StudioContext";
20
12
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
@@ -23,9 +15,7 @@ import { useDomEditContext } from "../contexts/DomEditContext";
23
15
  import { usePlayerStore } from "../player";
24
16
 
25
17
  export interface StudioRightPanelProps {
26
- selectedStudioMotion: StudioMotionData | null;
27
18
  designPanelActive: boolean;
28
- motionPanelActive: boolean;
29
19
  activeBlockParams?: {
30
20
  blockName: string;
31
21
  blockTitle: string;
@@ -40,9 +30,7 @@ export interface StudioRightPanelProps {
40
30
 
41
31
  // fallow-ignore-next-line complexity
42
32
  export function StudioRightPanel({
43
- selectedStudioMotion,
44
33
  designPanelActive,
45
- motionPanelActive,
46
34
  activeBlockParams,
47
35
  onCloseBlockParams,
48
36
  recordingState,
@@ -84,8 +72,6 @@ export function StudioRightPanel({
84
72
  handleDomAddTextField,
85
73
  handleDomRemoveTextField,
86
74
  handleAskAgent,
87
- handleDomMotionCommit,
88
- handleDomMotionClear,
89
75
  selectedGsapAnimations,
90
76
  gsapMultipleTimelines,
91
77
  gsapUnsupportedTimelinePattern,
@@ -159,21 +145,6 @@ export function StudioRightPanel({
159
145
  Layers
160
146
  </button>
161
147
  </Tooltip>
162
- {STUDIO_MOTION_PANEL_ENABLED && (
163
- <Tooltip label="Animation and motion" side="bottom">
164
- <button
165
- type="button"
166
- onClick={() => setRightPanelTab("motion")}
167
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
168
- rightPanelTab === "motion"
169
- ? "bg-neutral-800 text-white"
170
- : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
171
- }`}
172
- >
173
- Motion
174
- </button>
175
- </Tooltip>
176
- )}
177
148
  </>
178
149
  )}
179
150
  <Tooltip label="Render queue and exports" side="bottom">
@@ -248,14 +219,6 @@ export function StudioRightPanel({
248
219
  recordingDuration={recordingDuration}
249
220
  onToggleRecording={onToggleRecording}
250
221
  />
251
- ) : motionPanelActive ? (
252
- <MotionPanel
253
- element={domEditGroupSelections.length > 1 ? null : domEditSelection}
254
- motion={selectedStudioMotion}
255
- onClearSelection={clearDomSelection}
256
- onSetMotion={handleDomMotionCommit}
257
- onClearMotion={handleDomMotionClear}
258
- />
259
222
  ) : (
260
223
  <RenderQueue
261
224
  jobs={renderJobs}
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React, { act } from "react";
4
4
  import { createRoot } from "react-dom/client";
5
- import { describe, expect, it, vi } from "vitest";
5
+ import { beforeEach, describe, expect, it, vi } from "vitest";
6
6
  import { Window } from "happy-dom";
7
7
  import {
8
8
  DomEditOverlay,
@@ -19,13 +19,21 @@ import type { DomEditSelection } from "./domEditing";
19
19
  // React 19 warns unless the test environment opts into act().
20
20
  globalThis.IS_REACT_ACT_ENVIRONMENT = true;
21
21
 
22
+ const gestureSpies = vi.hoisted(() => ({
23
+ startGesture: vi.fn(() => true),
24
+ startGroupDrag: vi.fn(),
25
+ onPointerMove: vi.fn(),
26
+ onPointerUp: vi.fn(),
27
+ clearPointerState: vi.fn(),
28
+ }));
29
+
22
30
  vi.mock("./useDomEditOverlayGestures", () => ({
23
31
  createDomEditOverlayGestureHandlers: () => ({
24
- startGesture: () => true,
25
- startGroupDrag: () => {},
26
- onPointerMove: () => {},
27
- onPointerUp: () => {},
28
- clearPointerState: () => {},
32
+ startGesture: gestureSpies.startGesture,
33
+ startGroupDrag: gestureSpies.startGroupDrag,
34
+ onPointerMove: gestureSpies.onPointerMove,
35
+ onPointerUp: gestureSpies.onPointerUp,
36
+ clearPointerState: gestureSpies.clearPointerState,
29
37
  }),
30
38
  }));
31
39
 
@@ -34,9 +42,18 @@ vi.mock("./useDomEditOverlayRects", async () => {
34
42
  const { rectsEqual } = await import("./domEditOverlayGeometry");
35
43
 
36
44
  return {
37
- useDomEditOverlayRects: () => {
38
- const [overlayRect, setOverlayRectState] = React.useState(null);
39
- const overlayRectRef = React.useRef(null);
45
+ useDomEditOverlayRects: (options: { selectionRef: { current: unknown } }) => {
46
+ const defaultSelectionRect = {
47
+ left: 24,
48
+ top: 36,
49
+ width: 180,
50
+ height: 72,
51
+ editScaleX: 1,
52
+ editScaleY: 1,
53
+ };
54
+ const initialOverlayRect = options.selectionRef.current ? defaultSelectionRect : null;
55
+ const [overlayRect, setOverlayRectState] = React.useState(initialOverlayRect);
56
+ const overlayRectRef = React.useRef(initialOverlayRect);
40
57
  const [groupOverlayItems, setGroupOverlayItemsState] = React.useState([]);
41
58
  const groupOverlayItemsRef = React.useRef([]);
42
59
 
@@ -85,6 +102,30 @@ vi.mock("./domEditOverlayGeometry", async () => {
85
102
  };
86
103
  });
87
104
 
105
+ function createOverlayProps(args: {
106
+ iframeRef: { current: HTMLIFrameElement | null };
107
+ selection: DomEditSelection | null;
108
+ hoverSelection: DomEditSelection | null;
109
+ onSelectionChange: (next: DomEditSelection) => void;
110
+ }) {
111
+ return {
112
+ iframeRef: args.iframeRef,
113
+ activeCompositionPath: null,
114
+ selection: args.selection,
115
+ hoverSelection: args.hoverSelection,
116
+ groupSelections: [],
117
+ onCanvasMouseDown: () => {},
118
+ onCanvasPointerMove: () => Promise.resolve(args.hoverSelection ?? args.selection),
119
+ onCanvasPointerLeave: () => {},
120
+ onSelectionChange: args.onSelectionChange,
121
+ onBlockedMove: () => {},
122
+ onPathOffsetCommit: () => {},
123
+ onGroupPathOffsetCommit: () => {},
124
+ onBoxSizeCommit: () => {},
125
+ onRotationCommit: () => {},
126
+ };
127
+ }
128
+
88
129
  describe("focusDomEditOverlayElement", () => {
89
130
  it("focuses the canvas overlay without scrolling", () => {
90
131
  const calls: Array<FocusOptions | undefined> = [];
@@ -97,7 +138,94 @@ describe("focusDomEditOverlayElement", () => {
97
138
  });
98
139
 
99
140
  describe("DomEditOverlay", () => {
100
- it("renders selected bounds right after clicking a movable selection", async () => {
141
+ beforeEach(() => {
142
+ gestureSpies.startGesture.mockClear();
143
+ gestureSpies.startGroupDrag.mockClear();
144
+ gestureSpies.onPointerMove.mockClear();
145
+ gestureSpies.onPointerUp.mockClear();
146
+ gestureSpies.clearPointerState.mockClear();
147
+ });
148
+
149
+ it("does not start a drag from a stale hover target on canvas pointer-down", () => {
150
+ const host = document.createElement("div");
151
+ document.body.append(host);
152
+ const root = createRoot(host);
153
+ const selection: DomEditSelection = {
154
+ element: document.createElement("div"),
155
+ id: "cta-label",
156
+ selector: ".cta-label",
157
+ selectorIndex: 0,
158
+ sourceFile: "index.html",
159
+ tagName: "span",
160
+ label: "CTA Label",
161
+ textContent: "Add to basket",
162
+ textFields: [],
163
+ capabilities: {
164
+ canEditText: true,
165
+ canEditLayout: true,
166
+ canMove: true,
167
+ canApplyManualOffset: true,
168
+ canApplyManualSize: false,
169
+ canApplyManualRotation: false,
170
+ canAdjustOpacity: true,
171
+ canAdjustFill: true,
172
+ canAdjustBorderRadius: true,
173
+ canAdjustStroke: true,
174
+ canAdjustShadow: true,
175
+ canAdjustZIndex: true,
176
+ },
177
+ computedStyle: {
178
+ display: "inline",
179
+ position: "static",
180
+ },
181
+ };
182
+
183
+ let currentSelection: DomEditSelection | null = null;
184
+ const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
185
+
186
+ function Harness() {
187
+ const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
188
+ currentSelection = selected;
189
+
190
+ return React.createElement(
191
+ DomEditOverlay,
192
+ createOverlayProps({
193
+ iframeRef,
194
+ selection: selected,
195
+ hoverSelection: selection,
196
+ onSelectionChange: (next: DomEditSelection) => setSelected(next),
197
+ }),
198
+ );
199
+ }
200
+
201
+ act(() => {
202
+ root.render(React.createElement(Harness));
203
+ });
204
+
205
+ const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
206
+ expect(overlay).toBeTruthy();
207
+
208
+ act(() => {
209
+ overlay.dispatchEvent(
210
+ new PointerEvent("pointerdown", {
211
+ bubbles: true,
212
+ button: 0,
213
+ clientX: 120,
214
+ clientY: 80,
215
+ }),
216
+ );
217
+ });
218
+
219
+ expect(gestureSpies.startGesture).not.toHaveBeenCalled();
220
+ expect(currentSelection).toBe(null);
221
+
222
+ act(() => {
223
+ root.unmount();
224
+ });
225
+ host.remove();
226
+ });
227
+
228
+ it("starts movement from the selected bounds", async () => {
101
229
  // The overlay's compRect updates via a RAF loop reading iframe + overlay
102
230
  // getBoundingClientRect. happy-dom returns all zeros for newly-created
103
231
  // elements with no layout, so without stubs the RAF early-returns
@@ -153,31 +281,25 @@ describe("DomEditOverlay", () => {
153
281
  },
154
282
  };
155
283
 
156
- let currentSelection: DomEditSelection | null = null;
284
+ let currentSelection: DomEditSelection | null = selection;
285
+ const onToggleRecording = vi.fn();
157
286
  const iframeRef = { current: document.createElement("iframe") as HTMLIFrameElement | null };
158
287
  const originalPointerCapture = HTMLDivElement.prototype.setPointerCapture;
159
288
  HTMLDivElement.prototype.setPointerCapture = () => {};
160
289
 
161
290
  function Harness() {
162
- const [selected, setSelected] = React.useState<DomEditSelection | null>(null);
291
+ const [selected, setSelected] = React.useState<DomEditSelection | null>(selection);
163
292
  currentSelection = selected;
164
293
 
165
294
  return React.createElement(DomEditOverlay, {
166
- iframeRef,
167
- activeCompositionPath: null,
168
- selection: selected,
169
- // Simulate the element being hovered before pointer-down (real users always hover first)
170
- hoverSelection: selection,
171
- groupSelections: [],
172
- onCanvasMouseDown: () => {},
173
- onCanvasPointerMove: () => Promise.resolve(selection),
174
- onCanvasPointerLeave: () => {},
175
- onSelectionChange: (next: DomEditSelection) => setSelected(next),
176
- onBlockedMove: () => {},
177
- onPathOffsetCommit: () => {},
178
- onGroupPathOffsetCommit: () => {},
179
- onBoxSizeCommit: () => {},
180
- onRotationCommit: () => {},
295
+ ...createOverlayProps({
296
+ iframeRef,
297
+ selection: selected,
298
+ hoverSelection: null,
299
+ onSelectionChange: (next: DomEditSelection) => setSelected(next),
300
+ }),
301
+ recordingState: "idle",
302
+ onToggleRecording,
181
303
  });
182
304
  }
183
305
 
@@ -197,8 +319,13 @@ describe("DomEditOverlay", () => {
197
319
  const overlay = host.querySelector('[aria-label="Composition canvas"]') as HTMLDivElement;
198
320
  expect(overlay).toBeTruthy();
199
321
 
322
+ const selectionBox = host.querySelector(
323
+ '[data-dom-edit-selection-box="true"]',
324
+ ) as HTMLDivElement;
325
+ expect(selectionBox).toBeTruthy();
326
+
200
327
  act(() => {
201
- overlay.dispatchEvent(
328
+ selectionBox.dispatchEvent(
202
329
  new PointerEvent("pointerdown", {
203
330
  bubbles: true,
204
331
  button: 0,
@@ -209,7 +336,20 @@ describe("DomEditOverlay", () => {
209
336
  });
210
337
 
211
338
  expect(currentSelection).toBe(selection);
212
- expect(host.querySelector('[data-dom-edit-selection-box="true"]')).toBeTruthy();
339
+ expect(gestureSpies.startGesture).toHaveBeenCalledWith(
340
+ "drag",
341
+ expect.objectContaining({ button: 0 }),
342
+ );
343
+ const recordButton = host.querySelector(
344
+ '[aria-label="Record gesture (R)"]',
345
+ ) as HTMLButtonElement;
346
+ expect(recordButton).toBeTruthy();
347
+
348
+ act(() => {
349
+ recordButton.click();
350
+ });
351
+
352
+ expect(onToggleRecording).toHaveBeenCalledTimes(1);
213
353
 
214
354
  act(() => {
215
355
  root.unmount();
@@ -1,7 +1,7 @@
1
1
  import { memo, useMemo, useRef, useState, type RefObject } from "react";
2
2
  import { useMountEffect } from "../../hooks/useMountEffect";
3
3
  import { type DomEditSelection } from "./domEditing";
4
- import { resolveDomEditGroupOverlayRect, toOverlayRect } from "./domEditOverlayGeometry";
4
+ import { resolveDomEditGroupOverlayRect } from "./domEditOverlayGeometry";
5
5
  import {
6
6
  type BlockedMoveState,
7
7
  type FocusableDomEditOverlay,
@@ -13,6 +13,7 @@ import { useDomEditOverlayRects } from "./useDomEditOverlayRects";
13
13
  import { createDomEditOverlayGestureHandlers } from "./useDomEditOverlayGestures";
14
14
  import { SnapGuideOverlay, type SnapGuidesState } from "./SnapGuideOverlay";
15
15
  import { GridOverlay } from "./GridOverlay";
16
+ import { GestureRecordBadge, type GestureRecordingState } from "./GestureRecordControl";
16
17
 
17
18
  // Re-exports for external consumers — preserving existing import paths.
18
19
  export {
@@ -66,6 +67,8 @@ interface DomEditOverlayProps {
66
67
  onRotationCommit: (selection: DomEditSelection, next: { angle: number }) => Promise<void> | void;
67
68
  gridVisible?: boolean;
68
69
  gridSpacing?: number;
70
+ recordingState?: GestureRecordingState;
71
+ onToggleRecording?: () => void;
69
72
  }
70
73
 
71
74
  export const DomEditOverlay = memo(function DomEditOverlay({
@@ -87,6 +90,8 @@ export const DomEditOverlay = memo(function DomEditOverlay({
87
90
  onGroupPathOffsetCommit,
88
91
  onBoxSizeCommit,
89
92
  onRotationCommit,
93
+ recordingState,
94
+ onToggleRecording,
90
95
  }: DomEditOverlayProps) {
91
96
  const overlayRef = useRef<HTMLDivElement | null>(null);
92
97
  const boxRef = useRef<HTMLDivElement | null>(null);
@@ -304,28 +309,6 @@ export const DomEditOverlay = memo(function DomEditOverlay({
304
309
 
305
310
  const target = event.target as HTMLElement | null;
306
311
  if (target?.closest('[data-dom-edit-selection-box="true"]')) return;
307
-
308
- const candidate = hoverSelectionRef.current;
309
- if (!candidate?.capabilities.canApplyManualOffset) return;
310
-
311
- const overlayEl = overlayRef.current;
312
- const iframe = iframeRef.current;
313
- const candidateRect =
314
- overlayEl && iframe ? toOverlayRect(overlayEl, iframe, candidate.element) : null;
315
- if (!candidateRect) return;
316
-
317
- suppressNextOverlayMouseDownRef.current = true;
318
- selectionRef.current = candidate;
319
- setOverlayRect(candidateRect);
320
- const didStartGesture = gestures.startGesture("drag", event, {
321
- selection: candidate,
322
- rect: candidateRect,
323
- });
324
- if (!didStartGesture) {
325
- suppressNextOverlayMouseDownRef.current = false;
326
- return;
327
- }
328
- onSelectionChangeRef.current(candidate);
329
312
  };
330
313
 
331
314
  const handleBoxClick = (event: React.MouseEvent<HTMLDivElement>) => {
@@ -453,6 +436,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({
453
436
  />
454
437
  </div>
455
438
  )}
439
+ {onToggleRecording && (
440
+ <GestureRecordBadge
441
+ rect={overlayRect}
442
+ recordingState={recordingState}
443
+ onToggleRecording={onToggleRecording}
444
+ />
445
+ )}
456
446
  <div
457
447
  key={selectionKey}
458
448
  ref={boxRef}
@@ -0,0 +1,98 @@
1
+ export type GestureRecordingState = "idle" | "recording" | "preview";
2
+
3
+ interface GestureRecordIconProps {
4
+ recording: boolean;
5
+ }
6
+
7
+ function GestureRecordIcon({ recording }: GestureRecordIconProps) {
8
+ return (
9
+ <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
10
+ {recording ? (
11
+ <rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
12
+ ) : (
13
+ <circle cx="5" cy="5" r="4.5" fill="currentColor" />
14
+ )}
15
+ </svg>
16
+ );
17
+ }
18
+
19
+ interface GestureRecordPanelButtonProps {
20
+ recordingState?: GestureRecordingState;
21
+ recordingDuration?: number;
22
+ onToggleRecording: () => void;
23
+ }
24
+
25
+ export function GestureRecordPanelButton({
26
+ recordingState,
27
+ recordingDuration,
28
+ onToggleRecording,
29
+ }: GestureRecordPanelButtonProps) {
30
+ const recording = recordingState === "recording";
31
+
32
+ return (
33
+ <div className="px-4 pb-3">
34
+ <button
35
+ type="button"
36
+ onMouseDown={(e) => e.preventDefault()}
37
+ onClick={onToggleRecording}
38
+ className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
39
+ recording
40
+ ? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
41
+ : "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
42
+ }`}
43
+ >
44
+ <GestureRecordIcon recording={recording} />
45
+ {recording
46
+ ? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s — press R`
47
+ : "Record gesture (R) — move pointer to capture motion"}
48
+ </button>
49
+ </div>
50
+ );
51
+ }
52
+
53
+ interface GestureRecordBadgeProps {
54
+ rect: { left: number; top: number; width: number; height: number };
55
+ recordingState?: GestureRecordingState;
56
+ onToggleRecording: () => void;
57
+ }
58
+
59
+ export function GestureRecordBadge({
60
+ rect,
61
+ recordingState,
62
+ onToggleRecording,
63
+ }: GestureRecordBadgeProps) {
64
+ const recording = recordingState === "recording";
65
+ const label = recording ? "Stop gesture recording (R)" : "Record gesture (R)";
66
+
67
+ return (
68
+ <button
69
+ type="button"
70
+ aria-label={label}
71
+ title={label}
72
+ className={`pointer-events-auto absolute z-20 flex h-7 w-7 items-center justify-center rounded-full border shadow-lg transition-colors ${
73
+ recording
74
+ ? "border-red-400/60 bg-red-500 text-white animate-pulse"
75
+ : "border-studio-accent/60 bg-neutral-950 text-studio-accent hover:bg-neutral-900"
76
+ }`}
77
+ style={{
78
+ left: Math.max(0, rect.left + rect.width + 8),
79
+ top: Math.max(0, rect.top - 4),
80
+ }}
81
+ onPointerDown={(event) => {
82
+ event.preventDefault();
83
+ event.stopPropagation();
84
+ }}
85
+ onMouseDown={(event) => {
86
+ event.preventDefault();
87
+ event.stopPropagation();
88
+ }}
89
+ onClick={(event) => {
90
+ event.preventDefault();
91
+ event.stopPropagation();
92
+ onToggleRecording();
93
+ }}
94
+ >
95
+ <GestureRecordIcon recording={recording} />
96
+ </button>
97
+ );
98
+ }