@hyperframes/studio 0.8.51 → 0.8.52

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 (99) hide show
  1. package/dist/assets/{hyperframes-player-Cn_PpHqP.js → hyperframes-player-I5YRzB02.js} +1 -1
  2. package/dist/assets/{index-Clrb11lj.js → index-BTAfgesM.js} +1 -1
  3. package/dist/assets/index-CxAu_Okg.css +1 -0
  4. package/dist/assets/index-DocJlW2v.js +376 -0
  5. package/dist/assets/{index-CPS3Cvj8.js → index-Du5x7KUR.js} +1 -1
  6. package/dist/index.d.ts +401 -7
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +6545 -5869
  9. package/dist/index.js.map +1 -1
  10. package/package.json +9 -7
  11. package/src/App.tsx +3 -2
  12. package/src/captions/components/CaptionOverlay.sender.test.tsx +52 -0
  13. package/src/captions/components/CaptionOverlay.tsx +8 -4
  14. package/src/components/EditorShell.tsx +4 -0
  15. package/src/components/RightPanelTabs.test.tsx +151 -0
  16. package/src/components/RightPanelTabs.tsx +59 -0
  17. package/src/components/StudioHeader.dom.test.tsx +181 -0
  18. package/src/components/StudioHeader.tsx +109 -149
  19. package/src/components/StudioRightPanel.tsx +47 -35
  20. package/src/components/TimelineHistoryButtons.dom.test.tsx +117 -0
  21. package/src/components/TimelineHistoryButtons.tsx +54 -0
  22. package/src/components/TimelineToolbar.test.tsx +8 -0
  23. package/src/components/TimelineToolbar.tsx +3 -7
  24. package/src/components/dock/Dock.test.tsx +84 -16
  25. package/src/components/dock/Dock.tsx +19 -2
  26. package/src/components/dock/dock.css +5 -0
  27. package/src/components/dock/dockAccessibility.test.ts +166 -0
  28. package/src/components/dock/dockAccessibility.ts +98 -0
  29. package/src/components/dock/dockLayout.test.ts +134 -0
  30. package/src/components/dock/dockLayout.ts +53 -6
  31. package/src/components/dock/dockTestHarness.ts +11 -0
  32. package/src/components/editor/LayersPanel.test.ts +97 -1
  33. package/src/components/editor/LayersPanel.tsx +4 -2
  34. package/src/components/editor/PreviewGuides.tsx +1 -1
  35. package/src/components/editor/TopologyLens.test.tsx +28 -0
  36. package/src/components/editor/TopologyLens.tsx +3 -1
  37. package/src/components/editor/useColorGradingController.test.ts +38 -0
  38. package/src/components/editor/useColorGradingController.ts +9 -1
  39. package/src/components/editor/useColorGradingPreviews.test.ts +76 -0
  40. package/src/components/editor/useColorGradingPreviews.ts +11 -1
  41. package/src/components/editor/usePreviewCompositionRect.test.tsx +95 -0
  42. package/src/components/editor/usePreviewCompositionRect.ts +21 -0
  43. package/src/components/nle/NLEPreview.tsx +1 -1
  44. package/src/components/nle/PreviewPane.tsx +1 -1
  45. package/src/components/pointerTargetSize.test.tsx +8 -0
  46. package/src/components/renders/RenderQueue.tsx +1 -1
  47. package/src/components/timelineToolbarStyles.ts +5 -0
  48. package/src/components/ui/Button.test.tsx +222 -30
  49. package/src/components/ui/Button.tsx +88 -125
  50. package/src/components/ui/IconButton.tsx +38 -0
  51. package/src/components/ui/Input.tsx +115 -0
  52. package/src/components/ui/Menu.test.tsx +374 -0
  53. package/src/components/ui/Menu.tsx +213 -0
  54. package/src/components/ui/NumberField.tsx +122 -0
  55. package/src/components/ui/Popover.tsx +74 -0
  56. package/src/components/ui/Select.tsx +100 -0
  57. package/src/components/ui/Slider.tsx +153 -0
  58. package/src/components/ui/Tabs.test.tsx +98 -0
  59. package/src/components/ui/Tabs.tsx +71 -0
  60. package/src/components/ui/Toggle.tsx +63 -0
  61. package/src/components/ui/Tooltip.test.tsx +110 -0
  62. package/src/components/ui/Tooltip.tsx +38 -112
  63. package/src/components/ui/cn.test.ts +30 -0
  64. package/src/components/ui/cn.ts +25 -0
  65. package/src/components/ui/index.ts +29 -2
  66. package/src/components/ui/mountHost.testHelpers.ts +24 -0
  67. package/src/components/ui/valueControls.test.tsx +524 -0
  68. package/src/hooks/useAppHotkeys.ts +20 -79
  69. package/src/hooks/useCaptionDetection.sender.test.tsx +46 -0
  70. package/src/hooks/useCaptionDetection.ts +3 -1
  71. package/src/hooks/useEditHistoryActions.test.tsx +90 -0
  72. package/src/hooks/useEditHistoryActions.ts +106 -0
  73. package/src/hooks/useServerConnection.hashProject.test.tsx +94 -0
  74. package/src/hooks/useServerConnection.ts +43 -10
  75. package/src/index.ts +51 -0
  76. package/src/player/components/AutomationSelectionMenu.tsx +1 -1
  77. package/src/player/components/TimelineCanvas.tsx +4 -4
  78. package/src/player/components/TimelineClip.tsx +0 -1
  79. package/src/player/lib/playbackShortcuts.ts +2 -0
  80. package/src/player/store/previewIframeStore.notify.test.tsx +75 -0
  81. package/src/player/store/previewIframeStore.test.tsx +29 -0
  82. package/src/player/store/previewIframeStore.ts +25 -0
  83. package/src/renderQueueExports.test.tsx +58 -0
  84. package/src/styles/classCandidates.test.ts +105 -0
  85. package/src/styles/classCandidates.ts +281 -0
  86. package/src/styles/hex-baseline.json +84 -0
  87. package/src/styles/hexRatchet.test.ts +201 -0
  88. package/src/styles/styleSources.test.ts +20 -0
  89. package/src/styles/styleSources.ts +47 -0
  90. package/src/styles/theme.css +30 -0
  91. package/src/styles/theme.test.ts +2 -15
  92. package/src/styles/tokenGate.test.ts +109 -0
  93. package/src/utils/sourcePatcher.ts +1 -1
  94. package/src/utils/studioHelpers.test.ts +18 -0
  95. package/src/utils/studioHelpers.ts +11 -1
  96. package/src/utils/typingTarget.ts +4 -0
  97. package/dist/assets/index-C05SNrvY.js +0 -360
  98. package/dist/assets/index-DUR9qzQM.css +0 -1
  99. package/src/components/PanelTabButton.tsx +0 -31
@@ -1,10 +1,9 @@
1
1
  import type { MouseEvent } from "react";
2
- import { RotateCcw, RotateCw, Camera } from "../icons/SystemIcons";
3
- import { getHistoryShortcutLabel } from "../utils/studioHelpers";
2
+ import { Camera } from "../icons/SystemIcons";
4
3
  import { useStudioShellContext } from "../contexts/StudioContext";
5
4
  import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
6
5
  import { trackStudioEvent } from "../utils/studioTelemetry";
7
- import { Tooltip } from "./ui";
6
+ import { Button, buttonBase, buttonSizes, buttonVariants, cn, Tooltip } from "./ui";
8
7
 
9
8
  export interface StudioHeaderProps {
10
9
  captureFrameHref: string;
@@ -40,8 +39,8 @@ function HyperframesLogo() {
40
39
  y2="37.482"
41
40
  gradientUnits="userSpaceOnUse"
42
41
  >
43
- <stop stopColor="#06E3FA" />
44
- <stop offset="1" stopColor="#4FDB5E" />
42
+ <stop stopColor="var(--color-brand-gradient-from)" />
43
+ <stop offset="1" stopColor="var(--color-brand-gradient-to)" />
45
44
  </linearGradient>
46
45
  <linearGradient
47
46
  id="hf-g1"
@@ -51,8 +50,8 @@ function HyperframesLogo() {
51
50
  y2="6.303"
52
51
  gradientUnits="userSpaceOnUse"
53
52
  >
54
- <stop stopColor="#06E3FA" />
55
- <stop offset="1" stopColor="#4FDB5E" />
53
+ <stop stopColor="var(--color-brand-gradient-from)" />
54
+ <stop offset="1" stopColor="var(--color-brand-gradient-to)" />
56
55
  </linearGradient>
57
56
  </defs>
58
57
  {/* heygen label */}
@@ -164,7 +163,7 @@ export function StudioHeader({
164
163
  inspectorPanelActive,
165
164
  onExport,
166
165
  }: StudioHeaderProps) {
167
- const { projectId, editHistory, handleUndo, handleRedo, renderQueue } = useStudioShellContext();
166
+ const { projectId, renderQueue } = useStudioShellContext();
168
167
  // effectiveRightCollapsed, not the raw intent: in the auto-railed state the
169
168
  // intent is still "open" while the panel is hidden, so branching on intent
170
169
  // made this button write rightCollapsed=true — and that value is synced into
@@ -174,153 +173,114 @@ export function StudioHeader({
174
173
  const ffmpegMissing = renderQueue.ffmpegMissing;
175
174
 
176
175
  return (
177
- <div className="flex items-center justify-between h-10 px-3 bg-neutral-900 border-b border-neutral-800 shrink-0">
176
+ <div className="flex items-center justify-between h-10 px-3 bg-surface border-b border-border-strong shrink-0">
178
177
  {/* Left: logo + project name */}
179
178
  <div className="flex items-center gap-3">
180
179
  <HyperframesLogo />
181
- <span className="text-neutral-700 select-none" aria-hidden="true">
180
+ <span className="text-text-5 select-none" aria-hidden="true">
182
181
  |
183
182
  </span>
184
- <span className="text-[11px] font-medium text-neutral-300">{projectId}</span>
183
+ <span className="text-step-11 font-medium text-text-1">{projectId}</span>
185
184
  </div>
186
185
  {/* Right: toolbar buttons */}
187
- <div className="flex items-center gap-1.5">
188
- <Tooltip
189
- label={
190
- editHistory.undoLabel
191
- ? `Undo ${editHistory.undoLabel} (${getHistoryShortcutLabel("undo")})`
192
- : `Undo (${getHistoryShortcutLabel("undo")})`
193
- }
194
- side="bottom"
195
- >
196
- <button
197
- type="button"
198
- onClick={() => {
199
- trackStudioEvent("toolbar_action", { action: "undo" });
200
- void handleUndo();
201
- }}
202
- disabled={!editHistory.canUndo}
203
- className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
204
- editHistory.canUndo
205
- ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
206
- : "text-neutral-700 cursor-default"
207
- }`}
208
- aria-label="Undo"
209
- >
210
- <RotateCcw size={14} />
211
- </button>
212
- </Tooltip>
213
- <Tooltip
214
- label={
215
- editHistory.redoLabel
216
- ? `Redo ${editHistory.redoLabel} (${getHistoryShortcutLabel("redo")})`
217
- : `Redo (${getHistoryShortcutLabel("redo")})`
218
- }
219
- side="bottom"
220
- >
221
- <button
222
- type="button"
223
- onClick={() => {
224
- trackStudioEvent("toolbar_action", { action: "redo" });
225
- void handleRedo();
226
- }}
227
- disabled={!editHistory.canRedo}
228
- className={`h-7 w-7 flex items-center justify-center rounded-md transition-colors active:scale-[0.98] ${
229
- editHistory.canRedo
230
- ? "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800"
231
- : "text-neutral-700 cursor-default"
232
- }`}
233
- aria-label="Redo"
234
- >
235
- <RotateCw size={14} />
236
- </button>
237
- </Tooltip>
238
- <Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
239
- <a
240
- href={captureFrameHref}
241
- download={captureFrameFilename}
242
- onClick={(e) => {
243
- if (capturing) {
244
- e.preventDefault();
245
- return;
246
- }
247
- trackStudioEvent("toolbar_action", { action: "capture_frame" });
248
- handleCaptureFrameClick(e);
249
- }}
250
- onFocus={refreshCaptureFrameTime}
251
- onPointerDown={refreshCaptureFrameTime}
252
- aria-disabled={capturing || undefined}
253
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${
254
- capturing
255
- ? "text-neutral-600 cursor-default"
256
- : "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]"
257
- }`}
258
- aria-label={capturing ? "Capturing frame" : "Capture current frame"}
259
- >
260
- {capturing ? (
261
- <svg
262
- className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
263
- viewBox="0 0 24 24"
264
- fill="none"
265
- aria-hidden="true"
266
- >
267
- <circle
268
- className="opacity-25"
269
- cx="12"
270
- cy="12"
271
- r="10"
186
+ <div className="flex items-center gap-3">
187
+ <div className="flex h-ctl items-center divide-x divide-border-strong overflow-hidden rounded-md border border-border-strong bg-bg-2">
188
+ <Tooltip label={capturing ? "Capturing frame…" : "Capture current frame"} side="bottom">
189
+ {/* A real download link, so it wears Button's recipe rather than being
190
+ one: `download` on an <a> is what saves the frame, and no <button>
191
+ can do that. `enabled:` never matches a link, so the ghost
192
+ variant's hover look is repeated unprefixed here. */}
193
+ <a
194
+ href={captureFrameHref}
195
+ download={captureFrameFilename}
196
+ onClick={(e) => {
197
+ if (capturing) {
198
+ e.preventDefault();
199
+ return;
200
+ }
201
+ trackStudioEvent("toolbar_action", { action: "capture_frame" });
202
+ handleCaptureFrameClick(e);
203
+ }}
204
+ onFocus={refreshCaptureFrameTime}
205
+ onPointerDown={refreshCaptureFrameTime}
206
+ aria-disabled={capturing || undefined}
207
+ className={cn(
208
+ buttonBase,
209
+ buttonVariants.ghost,
210
+ buttonSizes.md,
211
+ "h-full rounded-none max-[1000px]:px-2",
212
+ capturing
213
+ ? "text-text-4 cursor-default"
214
+ : "hover:bg-hover hover:text-text-0 active:scale-[0.98]",
215
+ )}
216
+ aria-label={capturing ? "Capturing frame" : "Capture current frame"}
217
+ >
218
+ {capturing ? (
219
+ <svg
220
+ className="animate-spin motion-reduce:animate-none h-3.5 w-3.5"
221
+ viewBox="0 0 24 24"
222
+ fill="none"
223
+ aria-hidden="true"
224
+ >
225
+ <circle
226
+ className="opacity-25"
227
+ cx="12"
228
+ cy="12"
229
+ r="10"
230
+ stroke="currentColor"
231
+ strokeWidth="4"
232
+ />
233
+ <path
234
+ className="opacity-75"
235
+ fill="currentColor"
236
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
237
+ />
238
+ </svg>
239
+ ) : (
240
+ <Camera size={14} />
241
+ )}
242
+ <span className="max-[1000px]:hidden">{capturing ? "Capturing…" : "Capture"}</span>
243
+ </a>
244
+ </Tooltip>
245
+ <Tooltip label="Inspector" side="bottom">
246
+ <Button
247
+ variant="ghost"
248
+ aria-label="Inspector"
249
+ aria-pressed={inspectorButtonActive}
250
+ className={cn(
251
+ "h-full rounded-none",
252
+ inspectorButtonActive && "bg-hover text-accent enabled:hover:text-accent",
253
+ )}
254
+ icon={
255
+ <svg
256
+ width="12"
257
+ height="12"
258
+ viewBox="0 0 24 24"
259
+ fill="none"
272
260
  stroke="currentColor"
273
- strokeWidth="4"
274
- />
275
- <path
276
- className="opacity-75"
277
- fill="currentColor"
278
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
279
- />
280
- </svg>
281
- ) : (
282
- <Camera size={14} />
283
- )}
284
- <span>{capturing ? "Capturing…" : "Capture"}</span>
285
- </a>
286
- </Tooltip>
287
- <Tooltip label="Inspector" side="bottom">
288
- <button
289
- type="button"
290
- onClick={() => {
291
- if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
292
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
293
- setRightPanelTab("design");
294
- setRightCollapsed(false);
295
- return;
261
+ strokeWidth="2"
262
+ >
263
+ <circle cx="12" cy="12" r="10" />
264
+ <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
265
+ </svg>
296
266
  }
297
- trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
298
- // Keep the current selection when collapsing the Inspector — closing
299
- // the panel shouldn't deselect the element.
300
- setRightCollapsed(true);
301
- }}
302
- aria-pressed={inspectorButtonActive}
303
- className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium border transition-colors active:scale-[0.98] ${
304
- inspectorButtonActive
305
- ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
306
- : "text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 border-transparent"
307
- }`}
308
- aria-label="Inspector"
309
- >
310
- <svg
311
- width="12"
312
- height="12"
313
- viewBox="0 0 24 24"
314
- fill="none"
315
- stroke="currentColor"
316
- strokeWidth="2"
267
+ onClick={() => {
268
+ if (shouldOpenInspector(effectiveRightCollapsed, inspectorPanelActive)) {
269
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false });
270
+ setRightPanelTab("design");
271
+ setRightCollapsed(false);
272
+ return;
273
+ }
274
+ trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true });
275
+ // Keep the current selection when collapsing the Inspector — closing
276
+ // the panel shouldn't deselect the element.
277
+ setRightCollapsed(true);
278
+ }}
317
279
  >
318
- <circle cx="12" cy="12" r="10" />
319
- <polygon points="10 8 16 12 10 16" fill="currentColor" stroke="none" />
320
- </svg>
321
- Inspector
322
- </button>
323
- </Tooltip>
280
+ Inspector
281
+ </Button>
282
+ </Tooltip>
283
+ </div>
324
284
  <Tooltip
325
285
  label={
326
286
  ffmpegMissing
@@ -331,8 +291,9 @@ export function StudioHeader({
331
291
  }
332
292
  side="bottom"
333
293
  >
334
- <button
335
- type="button"
294
+ <Button
295
+ variant="primary"
296
+ data-testid="header-export"
336
297
  disabled={isRendering}
337
298
  onClick={() => {
338
299
  if (isRendering) return;
@@ -346,10 +307,9 @@ export function StudioHeader({
346
307
  if (ffmpegMissing) return;
347
308
  onExport?.();
348
309
  }}
349
- className="h-7 flex items-center gap-1.5 px-3 rounded-md text-[11px] font-semibold bg-studio-accent text-[#09090B] enabled:hover:brightness-110 transition-[filter,transform] enabled:active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
350
310
  >
351
311
  {isRendering ? "Rendering…" : "Export"}
352
- </button>
312
+ </Button>
353
313
  </Tooltip>
354
314
  </div>
355
315
  </div>
@@ -9,7 +9,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
9
9
  import { RenderQueuePanel } from "./renders/RenderQueuePanel";
10
10
  import { SlideshowPanel } from "./panels/SlideshowPanel";
11
11
  import { VariablesPanel } from "./panels/VariablesPanel";
12
- import { PanelTabButton } from "./PanelTabButton";
12
+ import { RightPanelTabs, type RightPanelTabDescriptor } from "./RightPanelTabs";
13
13
  import type { RenderJob } from "./renders/useRenderQueue";
14
14
  import { STUDIO_FLAT_INSPECTOR_ENABLED } from "./editor/manualEditingAvailability";
15
15
  import { useSlideshowPersist } from "../hooks/useSlideshowPersist";
@@ -339,6 +339,51 @@ export function StudioRightPanel({
339
339
 
340
340
  const renderQueuePanel = <RenderQueuePanel />;
341
341
 
342
+ // Slideshow appears only for a slideshow composition, so the strip is built
343
+ // rather than written out: a tab that is not in this list is not reachable by
344
+ // an arrow key either.
345
+ const inspectorTabs: RightPanelTabDescriptor[] = [
346
+ {
347
+ id: "design",
348
+ label: "Design",
349
+ tooltip: "Element styles and properties",
350
+ active: designPaneOpen,
351
+ onSelect: () => handleInspectorPaneButtonClick("design"),
352
+ },
353
+ {
354
+ id: "layers",
355
+ label: "Layers",
356
+ tooltip: "Composition layer stack",
357
+ active: layersPaneOpen,
358
+ onSelect: () => handleInspectorPaneButtonClick("layers"),
359
+ },
360
+ {
361
+ id: "renders",
362
+ label: renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders",
363
+ tooltip: "Render queue and exports",
364
+ active: rightPanelTab === "renders",
365
+ onSelect: () => setRightPanelTab("renders"),
366
+ },
367
+ ...(isSlideshowComposition
368
+ ? [
369
+ {
370
+ id: "slideshow",
371
+ label: "Slideshow",
372
+ tooltip: "Slideshow branching editor",
373
+ active: rightPanelTab === "slideshow",
374
+ onSelect: () => setRightPanelTab("slideshow"),
375
+ },
376
+ ]
377
+ : []),
378
+ {
379
+ id: "variables",
380
+ label: "Variables",
381
+ tooltip: "Template variables — declare, preview with values",
382
+ active: rightPanelTab === "variables",
383
+ onSelect: () => setRightPanelTab("variables"),
384
+ },
385
+ ];
386
+
342
387
  return (
343
388
  <>
344
389
  {/* Vertical resize divider: 3px visible seam, 13px hit zone via the inner div. */}
@@ -376,40 +421,7 @@ export function StudioRightPanel({
376
421
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
377
422
  ) : (
378
423
  <>
379
- <div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
380
- <PanelTabButton
381
- label="Design"
382
- tooltip="Element styles and properties"
383
- active={designPaneOpen}
384
- onClick={() => handleInspectorPaneButtonClick("design")}
385
- />
386
- <PanelTabButton
387
- label="Layers"
388
- tooltip="Composition layer stack"
389
- active={layersPaneOpen}
390
- onClick={() => handleInspectorPaneButtonClick("layers")}
391
- />
392
- <PanelTabButton
393
- label={renderJobs.length > 0 ? `Renders (${renderJobs.length})` : "Renders"}
394
- tooltip="Render queue and exports"
395
- active={rightPanelTab === "renders"}
396
- onClick={() => setRightPanelTab("renders")}
397
- />
398
- {isSlideshowComposition && (
399
- <PanelTabButton
400
- label="Slideshow"
401
- tooltip="Slideshow branching editor"
402
- active={rightPanelTab === "slideshow"}
403
- onClick={() => setRightPanelTab("slideshow")}
404
- />
405
- )}
406
- <PanelTabButton
407
- label="Variables"
408
- tooltip="Template variables — declare, preview with values"
409
- active={rightPanelTab === "variables"}
410
- onClick={() => setRightPanelTab("variables")}
411
- />
412
- </div>
424
+ <RightPanelTabs tabs={inspectorTabs} activateOnFocus={STUDIO_FLAT_INSPECTOR_ENABLED} />
413
425
  <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
414
426
  {rightPanelTab === "block-params" && activeBlockParams ? (
415
427
  <BlockParamsPanel
@@ -0,0 +1,117 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act } from "react";
4
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
5
+ import { cleanupMounted, mountHost } from "./ui/mountHost.testHelpers";
6
+ import { isTypingTarget } from "../utils/typingTarget";
7
+ import { shouldIgnorePlaybackShortcutTarget } from "../player/lib/playbackShortcuts";
8
+
9
+ const editHistory = {
10
+ canUndo: false,
11
+ canRedo: false,
12
+ undoLabel: undefined as string | undefined,
13
+ redoLabel: undefined as string | undefined,
14
+ };
15
+ const handleUndo = vi.fn();
16
+ const handleRedo = vi.fn();
17
+ const trackStudioEvent = vi.fn();
18
+
19
+ vi.mock("../contexts/StudioContext", () => ({
20
+ useStudioShellContext: () => ({ editHistory, handleUndo, handleRedo }),
21
+ }));
22
+ vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent }));
23
+
24
+ const { TimelineHistoryButtons } = await import("./TimelineHistoryButtons");
25
+ const { TimelineToolbar } = await import("./TimelineToolbar");
26
+
27
+ beforeEach(() => {
28
+ Object.assign(editHistory, {
29
+ canUndo: false,
30
+ canRedo: false,
31
+ undoLabel: undefined,
32
+ redoLabel: undefined,
33
+ });
34
+ vi.clearAllMocks();
35
+ });
36
+
37
+ afterEach(cleanupMounted);
38
+
39
+ const mount = mountHost;
40
+
41
+ function button(host: HTMLElement, label: string): HTMLButtonElement {
42
+ const el = host.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`);
43
+ if (!el) throw new Error(`not rendered: ${label}`);
44
+ return el;
45
+ }
46
+
47
+ function click(el: HTMLElement): void {
48
+ act(() => {
49
+ el.dispatchEvent(new MouseEvent("click", { bubbles: true }));
50
+ });
51
+ }
52
+
53
+ it("calls handleUndo once and tracks the event when Undo is enabled", () => {
54
+ editHistory.canUndo = true;
55
+ const host = mount(<TimelineHistoryButtons />);
56
+
57
+ click(button(host, "Undo"));
58
+
59
+ expect(handleUndo).toHaveBeenCalledTimes(1);
60
+ expect(handleRedo).not.toHaveBeenCalled();
61
+ expect(trackStudioEvent).toHaveBeenCalledWith("toolbar_action", { action: "undo" });
62
+ });
63
+
64
+ it("calls handleRedo once and tracks the event when Redo is enabled", () => {
65
+ editHistory.canRedo = true;
66
+ const host = mount(<TimelineHistoryButtons />);
67
+
68
+ click(button(host, "Redo"));
69
+
70
+ expect(handleRedo).toHaveBeenCalledTimes(1);
71
+ expect(trackStudioEvent).toHaveBeenCalledWith("toolbar_action", { action: "redo" });
72
+ });
73
+
74
+ it("disables both buttons and ignores clicks on an empty history", () => {
75
+ const host = mount(<TimelineHistoryButtons />);
76
+
77
+ for (const label of ["Undo", "Redo"]) {
78
+ const el = button(host, label);
79
+ expect(el.disabled).toBe(true);
80
+ expect(el.className).toContain("cursor-not-allowed");
81
+ click(el);
82
+ }
83
+ expect(handleUndo).not.toHaveBeenCalled();
84
+ expect(handleRedo).not.toHaveBeenCalled();
85
+ expect(trackStudioEvent).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("leaves enabled buttons without the disabled attribute", () => {
89
+ editHistory.canUndo = true;
90
+ const el = button(mount(<TimelineHistoryButtons />), "Undo");
91
+
92
+ expect(el.disabled).toBe(false);
93
+ expect(el.className).not.toContain("cursor-not-allowed");
94
+ });
95
+
96
+ it("orders the toolbar Undo, Redo, Select, Razor", () => {
97
+ const host = mount(<TimelineToolbar />);
98
+ const labels = Array.from(host.querySelectorAll("button"))
99
+ .map((b) => b.getAttribute("aria-label"))
100
+ .filter((l) => l !== null)
101
+ .slice(0, 4);
102
+
103
+ expect(labels).toEqual(["Undo", "Redo", "Selection tool", "Razor tool"]);
104
+ });
105
+
106
+ it("classifies Undo and Redo for the hotkey filters at their new location (KTD13)", () => {
107
+ // The header's own version of this check dropped Undo/Redo when they moved
108
+ // here; a <button> is never a typing target and is always claimed by the
109
+ // playback filter, same as every other toolbar tool.
110
+ const host = mount(<TimelineHistoryButtons />);
111
+
112
+ for (const label of ["Undo", "Redo"]) {
113
+ const el = button(host, label);
114
+ expect(isTypingTarget(el), label).toBe(false);
115
+ expect(shouldIgnorePlaybackShortcutTarget(el), label).toBe(true);
116
+ }
117
+ });
@@ -0,0 +1,54 @@
1
+ import { useStudioShellContext } from "../contexts/StudioContext";
2
+ import { RotateCcw, RotateCw } from "../icons/SystemIcons";
3
+ import { trackStudioEvent } from "../utils/studioTelemetry";
4
+ import { historyTooltipLabel } from "../utils/studioHelpers";
5
+ import { flatDisabled, flatIdle } from "./timelineToolbarStyles";
6
+ import { Tooltip } from "./ui";
7
+
8
+ interface HistoryButtonProps {
9
+ action: "undo" | "redo";
10
+ enabled: boolean;
11
+ label: string | undefined;
12
+ onClick: () => Promise<void> | void;
13
+ }
14
+
15
+ function HistoryButton({ action, enabled, label, onClick }: HistoryButtonProps) {
16
+ const Icon = action === "undo" ? RotateCcw : RotateCw;
17
+ return (
18
+ <Tooltip label={historyTooltipLabel(action, label)}>
19
+ <button
20
+ type="button"
21
+ aria-label={action === "undo" ? "Undo" : "Redo"}
22
+ disabled={!enabled}
23
+ className={enabled ? flatIdle : flatDisabled}
24
+ onClick={() => {
25
+ trackStudioEvent("toolbar_action", { action });
26
+ void onClick();
27
+ }}
28
+ >
29
+ <Icon size={16} />
30
+ </button>
31
+ </Tooltip>
32
+ );
33
+ }
34
+
35
+ /** Undo and Redo, on the one edit-history path the shell already owns. */
36
+ export function TimelineHistoryButtons() {
37
+ const { editHistory, handleUndo, handleRedo } = useStudioShellContext();
38
+ return (
39
+ <>
40
+ <HistoryButton
41
+ action="undo"
42
+ enabled={editHistory.canUndo}
43
+ label={editHistory.undoLabel}
44
+ onClick={handleUndo}
45
+ />
46
+ <HistoryButton
47
+ action="redo"
48
+ enabled={editHistory.canRedo}
49
+ label={editHistory.redoLabel}
50
+ onClick={handleRedo}
51
+ />
52
+ </>
53
+ );
54
+ }
@@ -8,6 +8,14 @@ import { usePlayerStore } from "../player/store/playerStore";
8
8
  import { makeSelection } from "../hooks/domSelectionTestHarness";
9
9
  import { TimelineToolbar } from "./TimelineToolbar";
10
10
 
11
+ vi.mock("../contexts/StudioContext", () => ({
12
+ useStudioShellContext: () => ({
13
+ editHistory: { canUndo: false, canRedo: false },
14
+ handleUndo: vi.fn(),
15
+ handleRedo: vi.fn(),
16
+ }),
17
+ }));
18
+
11
19
  (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
12
20
 
13
21
  afterEach(() => {
@@ -22,6 +22,8 @@ import {
22
22
  import { useTimelineZoom } from "../player/components/useTimelineZoom";
23
23
  import { usePlayerStore, type TimelineElement } from "../player";
24
24
  import { Tooltip } from "./ui";
25
+ import { flatActive, flatBtn, flatDisabled, flatIdle } from "./timelineToolbarStyles";
26
+ import { TimelineHistoryButtons } from "./TimelineHistoryButtons";
25
27
  import { Scissors } from "../icons/SystemIcons";
26
28
  import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
27
29
  import type { DomEditSelection } from "./editor/domEditingTypes";
@@ -186,19 +188,13 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
186
188
  return () => window.removeEventListener("keydown", onKeyDown);
187
189
  }, []);
188
190
 
189
- // CapCut-flat icon buttons: no per-button border/box chrome — a transparent
190
- // 28px hit area with a subtle rounded hover wash, consistent 16px glyphs.
191
- const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
192
- const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/6 hover:text-neutral-200 active:scale-[0.98]`;
193
- const flatActive = `${flatBtn} bg-white/8 text-neutral-100 active:scale-[0.98]`;
194
- const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
195
-
196
191
  return (
197
192
  // The "TIMELINE" label is dropped for CapCut-like density — the pane's
198
193
  // position (tracks right below) makes it self-evident.
199
194
  <div className="border-b border-neutral-800/60">
200
195
  <div className="flex items-center justify-between px-2 py-0.5">
201
196
  <div className="flex items-center gap-0.5">
197
+ <TimelineHistoryButtons />
202
198
  <Tooltip label="Selection tool (V)">
203
199
  <button
204
200
  type="button"