@hyperframes/studio 0.8.6 → 0.8.7

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 (152) hide show
  1. package/dist/assets/{hyperframes-player-DdNgW-Np.js → hyperframes-player-bDcU464i.js} +1 -1
  2. package/dist/assets/index-Ba9zbpT9.css +1 -0
  3. package/dist/assets/index-CUXnyoIa.js +428 -0
  4. package/dist/assets/{index-bv8b5eiQ.js → index-FkAs3zRu.js} +1 -1
  5. package/dist/assets/{index-Ci7Uy8fR.js → index-MSOSP1-J.js} +1 -1
  6. package/dist/index.d.ts +98 -17
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +10336 -7195
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +22 -23
  12. package/src/captions/components/CaptionAnimationPanel.tsx +46 -13
  13. package/src/captions/components/CaptionOverlay.tsx +172 -29
  14. package/src/captions/components/CaptionOverlayUtils.ts +54 -0
  15. package/src/captions/components/CaptionPropertyPanel.tsx +49 -23
  16. package/src/captions/components/CaptionTimeline.tsx +36 -79
  17. package/src/captions/components/shared.tsx +76 -1
  18. package/src/captions/hooks/useCaptionSync.ts +72 -13
  19. package/src/captions/keyboard.test.ts +12 -0
  20. package/src/captions/keyboard.ts +12 -1
  21. package/src/captions/store.ts +120 -14
  22. package/src/components/EditorShell.selectionSync.test.tsx +1 -1
  23. package/src/components/EditorShell.tsx +13 -1
  24. package/src/components/StudioRightPanel.tsx +2 -0
  25. package/src/components/StudioRightPanel.types.ts +1 -0
  26. package/src/components/editor/AnimationCard.tsx +2 -1
  27. package/src/components/editor/AnimationCardParts.tsx +6 -3
  28. package/src/components/editor/ArcPathControls.tsx +10 -4
  29. package/src/components/editor/BlockParamsPanel.test.tsx +170 -0
  30. package/src/components/editor/BlockParamsPanel.tsx +153 -11
  31. package/src/components/editor/BorderRadiusEditor.tsx +3 -22
  32. package/src/components/editor/FileTree.tsx +41 -12
  33. package/src/components/editor/FileTreeNodes.tsx +90 -20
  34. package/src/components/editor/GsapAnimationList.tsx +5 -0
  35. package/src/components/editor/KeyframeDiamond.tsx +3 -1
  36. package/src/components/editor/KeyframeEaseList.tsx +3 -2
  37. package/src/components/editor/KeyframeNavigation.tsx +6 -2
  38. package/src/components/editor/LayersPanel.tsx +11 -2
  39. package/src/components/editor/MotionPathNode.tsx +1 -0
  40. package/src/components/editor/PropertyPanel.test.tsx +7 -6
  41. package/src/components/editor/PropertyPanel.tsx +13 -5
  42. package/src/components/editor/PropertyPanelFlat.tsx +2 -0
  43. package/src/components/editor/SnapToolbar.tsx +20 -4
  44. package/src/components/editor/TimelineFxPopover.test.tsx +113 -0
  45. package/src/components/editor/TimelineFxPopover.tsx +138 -0
  46. package/src/components/editor/Transform3DCube.tsx +22 -1
  47. package/src/components/editor/manualOffsetDrag.test.ts +49 -0
  48. package/src/components/editor/manualOffsetDrag.ts +13 -1
  49. package/src/components/editor/propertyPanel3dTransform.tsx +11 -3
  50. package/src/components/editor/propertyPanelAudioFxGroup.test.tsx +139 -3
  51. package/src/components/editor/propertyPanelAudioFxGroup.tsx +4 -0
  52. package/src/components/editor/propertyPanelColor.tsx +9 -1
  53. package/src/components/editor/propertyPanelColorGradingControls.tsx +28 -7
  54. package/src/components/editor/propertyPanelCommitField.tsx +9 -1
  55. package/src/components/editor/propertyPanelFill.tsx +50 -2
  56. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  57. package/src/components/editor/propertyPanelFont.tsx +45 -9
  58. package/src/components/editor/propertyPanelFxPresetStyle.ts +18 -0
  59. package/src/components/editor/propertyPanelFxSection.test.tsx +3 -3
  60. package/src/components/editor/propertyPanelFxSection.tsx +8 -23
  61. package/src/components/editor/propertyPanelPrimitives.tsx +9 -12
  62. package/src/components/editor/propertyPanelStyleSections.tsx +21 -2
  63. package/src/components/editor/propertyPanelTransformCommit.ts +3 -3
  64. package/src/components/editor/propertyPanelTypes.ts +2 -0
  65. package/src/components/editor/useApplyAudioFxPreset.ts +36 -0
  66. package/src/components/editor/useFxCarve.ts +0 -0
  67. package/src/components/editor/useFxCarveGrouping.ts +132 -0
  68. package/src/components/nle/PreviewOverlays.tsx +69 -2
  69. package/src/components/nle/PreviewPane.tsx +1 -0
  70. package/src/components/nle/useTimelineEditCallbacks.test.tsx +1 -0
  71. package/src/components/nle/useTimelineEditCallbacks.ts +27 -0
  72. package/src/components/panels/SlideshowPanel.tsx +81 -12
  73. package/src/components/panels/SlideshowSubPanels.tsx +61 -7
  74. package/src/components/sidebar/AssetCard.test.tsx +2 -2
  75. package/src/components/sidebar/AssetCard.tsx +94 -46
  76. package/src/components/sidebar/AssetContextMenu.tsx +196 -42
  77. package/src/components/sidebar/AssetsTab.tsx +156 -96
  78. package/src/components/sidebar/AudioRow.tsx +95 -40
  79. package/src/components/sidebar/BlocksTab.tsx +40 -128
  80. package/src/components/sidebar/CompositionsTab.tsx +46 -34
  81. package/src/components/sidebar/LeftSidebar.tsx +87 -75
  82. package/src/components/sidebar/PromptPreviewModal.tsx +126 -0
  83. package/src/components/sidebar/assetHelpers.ts +8 -0
  84. package/src/contexts/TimelineEditContext.tsx +5 -0
  85. package/src/hooks/timelineAudioGroupVolume.ts +148 -0
  86. package/src/hooks/timelineEditingHelpers.ts +78 -1
  87. package/src/hooks/timelineElementFxAttribute.ts +150 -0
  88. package/src/hooks/timelineTrackVisibility.test.ts +222 -1
  89. package/src/hooks/timelineTrackVisibility.ts +187 -4
  90. package/src/hooks/useAppHotkeys.ts +22 -0
  91. package/src/hooks/useAudioSoloBridge.ts +61 -0
  92. package/src/hooks/useCaptionDetection.ts +22 -2
  93. package/src/hooks/useDomEditCommits.test.tsx +24 -0
  94. package/src/hooks/useEffectiveTimelineDuration.ts +20 -0
  95. package/src/hooks/useGroupLevel.ts +36 -0
  96. package/src/hooks/useHydrateActiveCompPathFromUrl.ts +36 -0
  97. package/src/hooks/useMusicBeatAnalysis.ts +13 -0
  98. package/src/hooks/useTimelineDeleteOps.ts +170 -0
  99. package/src/hooks/useTimelineEditing.ts +53 -129
  100. package/src/player/components/AudioWaveform.tsx +29 -26
  101. package/src/player/components/BeatStrip.test.tsx +5 -9
  102. package/src/player/components/BeatStrip.tsx +12 -5
  103. package/src/player/components/ClipContextMenu.tsx +8 -2
  104. package/src/player/components/EditModal.tsx +48 -7
  105. package/src/player/components/KeyframeDiamondContextMenu.tsx +90 -10
  106. package/src/player/components/LayerDisclosureRow.tsx +12 -13
  107. package/src/player/components/Player.tsx +43 -8
  108. package/src/player/components/PlayerControls.tsx +107 -63
  109. package/src/player/components/ShortcutsPanel.tsx +23 -4
  110. package/src/player/components/SpeedMenu.tsx +34 -42
  111. package/src/player/components/Timeline.test.ts +7 -7
  112. package/src/player/components/Timeline.tsx +7 -10
  113. package/src/player/components/TimelineFxButton.test.tsx +81 -0
  114. package/src/player/components/TimelineFxButton.tsx +143 -0
  115. package/src/player/components/TimelineGroupBusStrip.test.tsx +146 -0
  116. package/src/player/components/TimelineGroupBusStrip.tsx +103 -0
  117. package/src/player/components/TimelineGroupHeader.tsx +176 -0
  118. package/src/player/components/TimelineGroupRow.tsx +146 -0
  119. package/src/player/components/TimelineLanes.test.tsx +5 -0
  120. package/src/player/components/TimelineLanes.tsx +39 -33
  121. package/src/player/components/TimelineOverlays.tsx +34 -0
  122. package/src/player/components/TimelineSoloButton.tsx +32 -0
  123. package/src/player/components/TimelineTrackHeader.tsx +74 -98
  124. package/src/player/components/TimelineTrackPlainHeader.tsx +119 -0
  125. package/src/player/components/VideoThumbnail.tsx +6 -1
  126. package/src/player/components/menuKeyboardNav.ts +47 -0
  127. package/src/player/components/timelineCallbacks.ts +26 -0
  128. package/src/player/components/timelineKeyboardNavigation.test.ts +8 -0
  129. package/src/player/components/timelineKeyboardNavigation.ts +147 -30
  130. package/src/player/components/timelineLaneProps.ts +3 -0
  131. package/src/player/components/timelineLayout.ts +1 -1
  132. package/src/player/components/timelineNavigationIdentity.ts +4 -0
  133. package/src/player/components/useTimelineLaneRowIndexes.ts +38 -0
  134. package/src/player/components/useTimelineLogicalFocus.ts +9 -0
  135. package/src/player/components/useTimelineLogicalRows.test.tsx +8 -0
  136. package/src/player/components/useTimelineLogicalRows.ts +17 -12
  137. package/src/player/components/useTimelineMultiDragActorWindows.ts +30 -0
  138. package/src/player/components/useTimelineTrackDerivations.ts +150 -6
  139. package/src/player/components/useTimelineTrackLayout.ts +61 -4
  140. package/src/player/hooks/previewMessageRouter.ts +127 -0
  141. package/src/player/hooks/useTimelinePlayer.ts +9 -47
  142. package/src/player/lib/timelineDOM.ts +51 -0
  143. package/src/player/store/audioSoloSlice.test.ts +113 -0
  144. package/src/player/store/audioSoloSlice.ts +43 -0
  145. package/src/player/store/editingModeSlice.ts +45 -0
  146. package/src/player/store/groupLevels.ts +33 -0
  147. package/src/player/store/keyframeSlice.ts +26 -0
  148. package/src/player/store/playerStore.ts +21 -22
  149. package/src/player/store/timelineElement.ts +10 -0
  150. package/src/telemetry/events.ts +12 -0
  151. package/dist/assets/index-Cy6fGN1u.js +0 -428
  152. package/dist/assets/index-DcF4s1PG.css +0 -1
@@ -9,17 +9,36 @@ import {
9
9
 
10
10
  let nextSplitId = 0;
11
11
 
12
+ const HISTORY_CAP = 50;
13
+ /** Coalesce rapid same-target edits (typing, nudging) into one history entry. */
14
+ const HISTORY_COALESCE_MS = 800;
15
+
12
16
  interface CaptionState {
13
17
  isEditMode: boolean;
18
+ /** User explicitly exited caption editing — suppresses auto re-activation. */
19
+ dismissed: boolean;
14
20
  model: CaptionModel | null;
15
21
  selectedSegmentIds: Set<string>;
16
22
  selectedGroupId: string | null;
17
23
  sourceFilePath: string | null;
24
+ /** Load/save failure surfaced to the user (null = healthy). */
25
+ syncError: string | null;
26
+ /** Registered by useCaptionSync so error banners can retry the save. */
27
+ retrySave: (() => void) | null;
28
+
29
+ // Undo/redo (in-memory model snapshots)
30
+ past: CaptionModel[];
31
+ future: CaptionModel[];
32
+ undo: () => CaptionModel | null;
33
+ redo: () => CaptionModel | null;
18
34
 
19
35
  // Basic
20
36
  setEditMode: (active: boolean) => void;
37
+ setDismissed: (dismissed: boolean) => void;
21
38
  setModel: (model: CaptionModel | null) => void;
22
39
  setSourceFilePath: (path: string | null) => void;
40
+ setSyncError: (error: string | null) => void;
41
+ setRetrySave: (fn: (() => void) | null) => void;
23
42
 
24
43
  // Selection
25
44
  selectSegment: (id: string, additive?: boolean) => void;
@@ -53,19 +72,70 @@ interface CaptionState {
53
72
 
54
73
  const initialState = {
55
74
  isEditMode: false,
75
+ dismissed: false,
56
76
  model: null,
57
77
  selectedSegmentIds: new Set<string>(),
58
78
  selectedGroupId: null,
59
79
  sourceFilePath: null,
80
+ syncError: null,
81
+ retrySave: null,
82
+ past: [] as CaptionModel[],
83
+ future: [] as CaptionModel[],
60
84
  };
61
85
 
86
+ // Coalescing bookkeeping lives outside the store — it is not renderable state.
87
+ let lastHistoryKey: string | null = null;
88
+ let lastHistoryAt = 0;
89
+
90
+ /**
91
+ * Snapshot the current model onto the undo stack before a mutation.
92
+ * `key` identifies the edit target so rapid repeats (typing a value, arrow-key
93
+ * nudging) coalesce into a single entry instead of one per keystroke.
94
+ */
95
+ function pushHistory(
96
+ state: Pick<CaptionState, "model" | "past">,
97
+ key: string,
98
+ ): Partial<Pick<CaptionState, "past" | "future">> {
99
+ if (!state.model) return {};
100
+ const now = Date.now();
101
+ if (lastHistoryKey === key && now - lastHistoryAt < HISTORY_COALESCE_MS) {
102
+ lastHistoryAt = now;
103
+ return { future: [] };
104
+ }
105
+ lastHistoryKey = key;
106
+ lastHistoryAt = now;
107
+ const past = [...state.past, state.model].slice(-HISTORY_CAP);
108
+ return { past, future: [] };
109
+ }
110
+
62
111
  export const useCaptionStore = create<CaptionState>((set, get) => ({
63
112
  ...initialState,
64
113
 
65
114
  // Basic
66
115
  setEditMode: (active) => set({ isEditMode: active }),
116
+ setDismissed: (dismissed) => set({ dismissed }),
67
117
  setModel: (model) => set({ model }),
68
118
  setSourceFilePath: (path) => set({ sourceFilePath: path }),
119
+ setSyncError: (error) => set({ syncError: error }),
120
+ setRetrySave: (fn) => set({ retrySave: fn }),
121
+
122
+ // Undo/redo
123
+ undo: () => {
124
+ const { model, past, future } = get();
125
+ const prev = past[past.length - 1];
126
+ if (!prev || !model) return null;
127
+ lastHistoryKey = null;
128
+ set({ model: prev, past: past.slice(0, -1), future: [...future, model] });
129
+ return prev;
130
+ },
131
+ redo: () => {
132
+ const { model, past, future } = get();
133
+ const next = future[future.length - 1];
134
+ if (!next || !model) return null;
135
+ lastHistoryKey = null;
136
+ set({ model: next, past: [...past, model].slice(-HISTORY_CAP), future: future.slice(0, -1) });
137
+ return next;
138
+ },
69
139
 
70
140
  // Selection
71
141
  selectSegment: (id, additive = false) =>
@@ -111,7 +181,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
111
181
  if (!segment) return {};
112
182
  const segments = new Map(state.model.segments);
113
183
  segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
114
- return { model: { ...state.model, segments } };
184
+ return {
185
+ ...pushHistory(state, `seg-style:${segmentId}`),
186
+ model: { ...state.model, segments },
187
+ };
115
188
  }),
116
189
 
117
190
  updateSegmentText: (segmentId, text) =>
@@ -121,7 +194,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
121
194
  if (!segment) return {};
122
195
  const segments = new Map(state.model.segments);
123
196
  segments.set(segmentId, { ...segment, text });
124
- return { model: { ...state.model, segments } };
197
+ return {
198
+ ...pushHistory(state, `seg-text:${segmentId}`),
199
+ model: { ...state.model, segments },
200
+ };
125
201
  }),
126
202
 
127
203
  updateSegmentTiming: (segmentId, start, end) =>
@@ -131,7 +207,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
131
207
  if (!segment) return {};
132
208
  const segments = new Map(state.model.segments);
133
209
  segments.set(segmentId, { ...segment, start, end });
134
- return { model: { ...state.model, segments } };
210
+ return {
211
+ ...pushHistory(state, `seg-timing:${segmentId}`),
212
+ model: { ...state.model, segments },
213
+ };
135
214
  }),
136
215
 
137
216
  // Group mutations
@@ -142,7 +221,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
142
221
  if (!group) return {};
143
222
  const groups = new Map(state.model.groups);
144
223
  groups.set(groupId, { ...group, style: { ...group.style, ...style } });
145
- return { model: { ...state.model, groups } };
224
+ return {
225
+ ...pushHistory(state, `group-style:${groupId}`),
226
+ model: { ...state.model, groups },
227
+ };
146
228
  }),
147
229
 
148
230
  updateGroupContainer: (groupId, container) =>
@@ -155,7 +237,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
155
237
  ...group,
156
238
  containerStyle: { ...group.containerStyle, ...container },
157
239
  });
158
- return { model: { ...state.model, groups } };
240
+ return {
241
+ ...pushHistory(state, `group-container:${groupId}`),
242
+ model: { ...state.model, groups },
243
+ };
159
244
  }),
160
245
 
161
246
  updateGroupAnimation: (groupId, phase, animation) =>
@@ -173,7 +258,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
173
258
  ...group,
174
259
  animation: { ...group.animation, [phase]: mergedPhase },
175
260
  });
176
- return { model: { ...state.model, groups } };
261
+ return {
262
+ ...pushHistory(state, `group-anim:${groupId}:${phase}`),
263
+ model: { ...state.model, groups },
264
+ };
177
265
  }),
178
266
 
179
267
  splitGroup: (groupId, atSegmentId) =>
@@ -206,7 +294,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
206
294
  }
207
295
  });
208
296
 
209
- return { model: { ...state.model, groups, segments, groupOrder } };
297
+ return {
298
+ ...pushHistory(state, `split:${groupId}:${atSegmentId}:${nextSplitId}`),
299
+ model: { ...state.model, groups, segments, groupOrder },
300
+ };
210
301
  }),
211
302
 
212
303
  mergeGroups: (groupId1, groupId2) =>
@@ -236,7 +327,11 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
236
327
  // Clear selection if it referenced group2
237
328
  const selectedGroupId = state.selectedGroupId === groupId2 ? null : state.selectedGroupId;
238
329
 
239
- return { model: { ...state.model, groups, segments, groupOrder }, selectedGroupId };
330
+ return {
331
+ ...pushHistory(state, `merge:${groupId1}:${groupId2}`),
332
+ model: { ...state.model, groups, segments, groupOrder },
333
+ selectedGroupId,
334
+ };
240
335
  }),
241
336
 
242
337
  // Bulk
@@ -250,7 +345,10 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
250
345
  segments.set(segmentId, { ...segment, style: { ...segment.style, ...style } });
251
346
  }
252
347
  }
253
- return { model: { ...state.model, segments } };
348
+ return {
349
+ ...pushHistory(state, `sel-style:${[...state.selectedSegmentIds].join(",")}`),
350
+ model: { ...state.model, segments },
351
+ };
254
352
  }),
255
353
 
256
354
  applyAnimationToAll: (animation) =>
@@ -260,13 +358,21 @@ export const useCaptionStore = create<CaptionState>((set, get) => ({
260
358
  for (const [id, group] of groups) {
261
359
  groups.set(id, { ...group, animation });
262
360
  }
263
- return { model: { ...state.model, groups } };
361
+ return {
362
+ ...pushHistory(state, "apply-anim-all"),
363
+ model: { ...state.model, groups },
364
+ };
264
365
  }),
265
366
 
266
- // Reset
267
- reset: () =>
268
- set({
367
+ // Reset — keeps retrySave (registered once by useCaptionSync for the app's lifetime)
368
+ reset: () => {
369
+ lastHistoryKey = null;
370
+ set((state) => ({
269
371
  ...initialState,
372
+ retrySave: state.retrySave,
270
373
  selectedSegmentIds: new Set<string>(),
271
- }),
374
+ past: [],
375
+ future: [],
376
+ }));
377
+ },
272
378
  }));
@@ -52,7 +52,6 @@ vi.mock("./nle/PreviewPane", () => ({ PreviewPane: () => null }));
52
52
  vi.mock("./nle/PreviewOverlays", () => ({ PreviewOverlays: () => null }));
53
53
  vi.mock("./nle/TimelinePane", () => ({ TimelinePane: () => null }));
54
54
  vi.mock("../captions/components/CaptionTimeline", () => ({ CaptionTimeline: () => null }));
55
- vi.mock("./StudioFeedbackBar", () => ({ StudioFeedbackBar: () => null }));
56
55
 
57
56
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
58
57
 
@@ -82,6 +81,7 @@ describe("EditorShell timeline selection sync", () => {
82
81
  handleTimelineElementResize={vi.fn()}
83
82
  handleTimelineGroupResize={vi.fn()}
84
83
  handleToggleTrackHidden={vi.fn()}
84
+ setAudioGroupAttribute={{ setLive: vi.fn(), setQuiet: vi.fn() }}
85
85
  handleBlockedTimelineEdit={vi.fn()}
86
86
  handleTimelineElementSplit={vi.fn()}
87
87
  handleRazorSplit={vi.fn()}
@@ -85,6 +85,9 @@ export function EditorShell({
85
85
  handleTimelineElementResize,
86
86
  handleTimelineGroupResize,
87
87
  handleToggleTrackHidden,
88
+ setAudioGroupAttribute,
89
+ handleGroupClips,
90
+ setElementFxAttribute,
88
91
  handleBlockedTimelineEdit,
89
92
  handleTimelineElementSplit,
90
93
  handleRazorSplit,
@@ -135,6 +138,9 @@ export function EditorShell({
135
138
  handleTimelineElementResize,
136
139
  handleTimelineGroupResize,
137
140
  handleToggleTrackHidden,
141
+ setAudioGroupAttribute,
142
+ handleGroupClips,
143
+ setElementFxAttribute,
138
144
  handleBlockedTimelineEdit,
139
145
  handleTimelineElementSplit,
140
146
  handleRazorSplit,
@@ -231,6 +237,12 @@ function EditorShellBody({
231
237
  }: EditorShellBodyProps) {
232
238
  const { compositionStack, updateCompositionStack, containerRef } = useNLEContext();
233
239
 
240
+ // The caption track's blocks are seek targets; CaptionTimeline took an onSeek
241
+ // prop that nothing ever passed, so clicking a block did nothing.
242
+ const seekCaptionTime = useCallback((time: number) => {
243
+ usePlayerStore.getState().requestSeek(time);
244
+ }, []);
245
+
234
246
  // Keyboard: Escape to pop composition level
235
247
  const handleKeyDown = useCallback(
236
248
  (e: React.KeyboardEvent) => {
@@ -282,7 +294,7 @@ function EditorShellBody({
282
294
  Captions
283
295
  </span>
284
296
  </div>
285
- <CaptionTimeline pixelsPerSecond={100} />
297
+ <CaptionTimeline pixelsPerSecond={100} onSeek={seekCaptionTime} />
286
298
  </div>
287
299
  ) : undefined
288
300
  }
@@ -45,6 +45,7 @@ export function StudioRightPanel({
45
45
  domEditSaveTimestampRef,
46
46
  recordEdit,
47
47
  onToggleElementHidden,
48
+ onAutoGroupCarveSources,
48
49
  onAddMediaOverlay,
49
50
  }: StudioRightPanelProps) {
50
51
  const {
@@ -328,6 +329,7 @@ export function StudioRightPanel({
328
329
  copiedAgentPrompt={copiedAgentPrompt}
329
330
  onClearSelection={clearDomSelection}
330
331
  onToggleElementHidden={onToggleElementHidden}
332
+ onAutoGroupCarveSources={onAutoGroupCarveSources}
331
333
  onUngroup={handleUngroupSelection}
332
334
  onSetStyle={handleDomStyleCommit}
333
335
  onSetAttribute={handleDomAttributeCommit}
@@ -51,5 +51,6 @@ export interface StudioRightPanelProps extends StudioEditPersistenceProps {
51
51
  files: Record<string, { before: string; after: string }>;
52
52
  }) => Promise<void>;
53
53
  onToggleElementHidden?: ToggleHiddenHandler;
54
+ onAutoGroupCarveSources?: (clipIds: readonly string[], groupId: string) => Promise<void>;
54
55
  onAddMediaOverlay?: AddMediaOverlayHandler;
55
56
  }
@@ -197,7 +197,8 @@ export const AnimationCard = memo(function AnimationCard({
197
197
  <button
198
198
  type="button"
199
199
  onClick={() => setExpanded((v) => !v)}
200
- className="flex w-full items-center gap-2 py-1.5"
200
+ aria-expanded={expanded}
201
+ className="flex w-full items-center gap-2 py-1.5 active:scale-[0.99]"
201
202
  >
202
203
  <span
203
204
  className="rounded bg-panel-accent/10 px-1.5 py-0.5 text-[10px] font-semibold text-panel-accent"
@@ -46,8 +46,9 @@ function RemoveButton({ onClick, title }: { onClick: () => void; title: string }
46
46
  <button
47
47
  type="button"
48
48
  onClick={onClick}
49
- className="flex-shrink-0 rounded p-0.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400"
49
+ className="relative flex-shrink-0 rounded p-1.5 text-neutral-600 transition-colors hover:bg-neutral-800 hover:text-red-400 active:scale-[0.95]"
50
50
  title={title}
51
+ aria-label={title}
51
52
  >
52
53
  <svg
53
54
  width="12"
@@ -87,13 +88,15 @@ export function PropertyRow({
87
88
  </span>
88
89
  <button
89
90
  type="button"
91
+ role="switch"
92
+ aria-checked={isVisible}
90
93
  onClick={() => onCommit(isVisible ? "hidden" : "visible")}
91
- className="flex-shrink-0 rounded-full transition-all duration-150 relative"
94
+ className="flex-shrink-0 rounded-full transition-colors duration-200 relative"
92
95
  style={{ width: 28, height: 16, background: isVisible ? P.accent : P.borderInput }}
93
96
  title={isVisible ? "Visible — click to hide" : "Hidden — click to show"}
94
97
  >
95
98
  <span
96
- className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
99
+ className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
97
100
  style={{
98
101
  width: 12,
99
102
  height: 12,
@@ -45,14 +45,17 @@ export const ArcPathControls = memo(function ArcPathControls({
45
45
  <span className={LABEL}>Arc Motion</span>
46
46
  <button
47
47
  type="button"
48
+ role="switch"
49
+ aria-checked={Boolean(arcPath.enabled)}
50
+ aria-label="Arc motion"
48
51
  onClick={handleToggle}
49
52
  disabled={disabled}
50
- className="relative rounded-full transition-all duration-150"
53
+ className="relative rounded-full transition-colors duration-200"
51
54
  style={{ width: 28, height: 16, background: arcPath.enabled ? P.accent : P.borderInput }}
52
55
  title={arcPath.enabled ? "Disable arc motion" : "Enable arc motion"}
53
56
  >
54
57
  <span
55
- className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
58
+ className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
56
59
  style={{
57
60
  width: 12,
58
61
  height: 12,
@@ -69,9 +72,12 @@ export const ArcPathControls = memo(function ArcPathControls({
69
72
  <span className={LABEL}>Auto-Rotate</span>
70
73
  <button
71
74
  type="button"
75
+ role="switch"
76
+ aria-checked={Boolean(arcPath.autoRotate)}
77
+ aria-label="Auto-rotate along path"
72
78
  onClick={handleAutoRotate}
73
79
  disabled={disabled}
74
- className="relative rounded-full transition-all duration-150"
80
+ className="relative rounded-full transition-colors duration-200"
75
81
  style={{
76
82
  width: 28,
77
83
  height: 16,
@@ -84,7 +90,7 @@ export const ArcPathControls = memo(function ArcPathControls({
84
90
  }
85
91
  >
86
92
  <span
87
- className="absolute top-[2px] left-0 rounded-full transition-transform duration-150"
93
+ className="absolute top-[2px] left-0 rounded-full transition-transform duration-200"
88
94
  style={{
89
95
  width: 12,
90
96
  height: 12,
@@ -0,0 +1,170 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot, type Root } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { BlockParam } from "@hyperframes/core/registry";
7
+ import { FileManagerProvider } from "../../contexts/FileManagerContext";
8
+ import type { useFileManager } from "../../hooks/useFileManager";
9
+ import { StudioPlaybackProvider, type StudioPlaybackValue } from "../../contexts/StudioContext";
10
+ import { BlockParamsPanel } from "./BlockParamsPanel";
11
+
12
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
13
+
14
+ vi.useFakeTimers();
15
+
16
+ const PARAMS: BlockParam[] = [
17
+ { key: "--bg-color", label: "Background", type: "color", default: "#0c0c0c" },
18
+ { key: "--text-color", label: "Text color", type: "color", default: "#fafafa" },
19
+ ];
20
+
21
+ const playbackValue: StudioPlaybackValue = {
22
+ captionEditMode: false,
23
+ compositionLoading: false,
24
+ refreshKey: 0,
25
+ setRefreshKey: vi.fn(),
26
+ timelineElements: [],
27
+ isPlaying: false,
28
+ refreshPreviewDocumentVersion: vi.fn(),
29
+ };
30
+
31
+ function makeFileManager(content: { value: string }) {
32
+ const readProjectFile = vi.fn(async () => content.value);
33
+ const writeProjectFile = vi.fn(async (_path: string, next: string) => {
34
+ content.value = next;
35
+ });
36
+ // The panel only touches read/writeProjectFile; the rest of the context
37
+ // surface is irrelevant to these tests.
38
+ const value = { readProjectFile, writeProjectFile } as unknown as ReturnType<
39
+ typeof useFileManager
40
+ >;
41
+ return { value, readProjectFile, writeProjectFile };
42
+ }
43
+
44
+ let root: Root | null = null;
45
+
46
+ afterEach(() => {
47
+ act(() => root?.unmount());
48
+ root = null;
49
+ document.body.innerHTML = "";
50
+ vi.clearAllTimers();
51
+ });
52
+
53
+ function renderPanel(fileManager: ReturnType<typeof makeFileManager>["value"]) {
54
+ const host = document.createElement("div");
55
+ document.body.append(host);
56
+ root = createRoot(host);
57
+ act(() => {
58
+ root?.render(
59
+ <StudioPlaybackProvider value={playbackValue}>
60
+ <FileManagerProvider value={fileManager}>
61
+ <BlockParamsPanel
62
+ blockName="vfx-demo"
63
+ blockTitle="VFX Demo"
64
+ params={PARAMS}
65
+ compositionPath="compositions/vfx-demo.html"
66
+ onClose={vi.fn()}
67
+ />
68
+ </FileManagerProvider>
69
+ </StudioPlaybackProvider>,
70
+ );
71
+ });
72
+ }
73
+
74
+ function changeParam(label: string, next: string) {
75
+ const input = document.querySelector<HTMLInputElement>(`input[aria-label="${label} value"]`);
76
+ if (!input) throw new Error(`no input for ${label}`);
77
+ act(() => {
78
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
79
+ setter?.call(input, next);
80
+ input.dispatchEvent(new Event("input", { bubbles: true }));
81
+ });
82
+ }
83
+
84
+ async function flushCommit() {
85
+ await act(async () => {
86
+ vi.advanceTimersByTime(350);
87
+ // Drain the read → guard → write promise chain.
88
+ await Promise.resolve();
89
+ await Promise.resolve();
90
+ await Promise.resolve();
91
+ await Promise.resolve();
92
+ });
93
+ }
94
+
95
+ describe("BlockParamsPanel commit safety", () => {
96
+ it("refuses a multi-occurrence value and never mutates the unrelated sibling", async () => {
97
+ // The param's default (#0c0c0c) also appears on an UNRELATED element. The
98
+ // panel has no way to tell which occurrence belongs to the param, so it
99
+ // must refuse rather than risk rewriting `.unrelated`. Nothing is written.
100
+ const content = {
101
+ value: ".block { background: #0c0c0c; } .unrelated { border-color: #0c0c0c; }",
102
+ };
103
+ const before = content.value;
104
+ const fm = makeFileManager(content);
105
+ renderPanel(fm.value);
106
+
107
+ changeParam("Background", "#123456");
108
+ await flushCommit();
109
+
110
+ expect(fm.writeProjectFile).not.toHaveBeenCalled();
111
+ expect(content.value).toBe(before); // both occurrences untouched
112
+ expect(content.value).toContain(".unrelated { border-color: #0c0c0c; }");
113
+ expect(document.body.textContent).toContain("appears 2×");
114
+ });
115
+
116
+ it("writes a unique occurrence with token boundaries (no substring corruption)", async () => {
117
+ // #0c0c0c is unique here — the 8-digit #0c0c0cff must NOT match (token
118
+ // boundary), so exactly one occurrence rewrites and the hex8 is preserved.
119
+ const content = {
120
+ value: ".a { background: #0c0c0c; } .c { color: #0c0c0cff; }",
121
+ };
122
+ const fm = makeFileManager(content);
123
+ renderPanel(fm.value);
124
+
125
+ changeParam("Background", "#123456");
126
+ await flushCommit();
127
+
128
+ expect(fm.writeProjectFile).toHaveBeenCalledTimes(1);
129
+ expect(content.value).toBe(".a { background: #123456; } .c { color: #0c0c0cff; }");
130
+ });
131
+
132
+ it("refuses once a previously-unique value later collides with unrelated content", async () => {
133
+ const content = {
134
+ value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
135
+ };
136
+ const fm = makeFileManager(content);
137
+ renderPanel(fm.value);
138
+
139
+ // #0c0c0c is unique → first commit writes; now #fafafa appears 2×.
140
+ changeParam("Background", "#fafafa");
141
+ await flushCommit();
142
+ expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
143
+
144
+ // Next edit's current value (#fafafa) now matches 2× → refuse.
145
+ changeParam("Background", "#ff0000");
146
+ await flushCommit();
147
+
148
+ expect(fm.writeProjectFile).toHaveBeenCalledTimes(1); // no second write
149
+ expect(content.value).toBe(".a { background: #fafafa; } .b { color: #fafafa; }");
150
+ expect(document.body.textContent).toContain("appears 2×");
151
+ });
152
+
153
+ it("keeps a pending commit for one param when another param is edited", async () => {
154
+ const content = {
155
+ value: ".a { background: #0c0c0c; } .b { color: #fafafa; }",
156
+ };
157
+ const fm = makeFileManager(content);
158
+ renderPanel(fm.value);
159
+
160
+ // Edit both params back-to-back within the 300ms debounce window: the
161
+ // second edit must not cancel the first param's pending commit.
162
+ changeParam("Background", "#111111");
163
+ changeParam("Text color", "#222222");
164
+ await flushCommit();
165
+ await flushCommit();
166
+
167
+ expect(content.value).toContain("#111111");
168
+ expect(content.value).toContain("#222222");
169
+ });
170
+ });