@hyperframes/studio 0.7.37 → 0.7.39

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 (109) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-0P10SwC_.css +1 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-312a3Ceu.js} +1 -1
  4. package/dist/assets/index-B9YvRJz1.js +396 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-CQHiecE7.js} +1 -1
  6. package/dist/{chunk-JND3XUJL.js → chunk-RCLGSZ7C.js} +1 -1
  7. package/dist/chunk-RCLGSZ7C.js.map +1 -0
  8. package/dist/{domEditingLayers-UIQZJCOA.js → domEditingLayers-S6YOLVRG.js} +2 -2
  9. package/dist/index.d.ts +48 -10
  10. package/dist/index.html +2 -2
  11. package/dist/index.js +6045 -3507
  12. package/dist/index.js.map +1 -1
  13. package/package.json +7 -7
  14. package/src/App.tsx +26 -24
  15. package/src/components/AskAgentModal.tsx +23 -6
  16. package/src/components/LintModal.tsx +40 -10
  17. package/src/components/MediaPreview.tsx +36 -1
  18. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  19. package/src/components/StudioErrorBoundary.tsx +19 -31
  20. package/src/components/StudioFeedbackBar.tsx +11 -2
  21. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  22. package/src/components/StudioHeader.tsx +184 -106
  23. package/src/components/StudioLeftSidebar.tsx +24 -3
  24. package/src/components/StudioOverlays.tsx +34 -8
  25. package/src/components/StudioPreviewArea.tsx +8 -0
  26. package/src/components/StudioRightPanel.tsx +160 -9
  27. package/src/components/StudioSplash.tsx +6 -3
  28. package/src/components/StudioToast.tsx +12 -11
  29. package/src/components/TimelineToolbar.test.tsx +9 -3
  30. package/src/components/TimelineToolbar.tsx +63 -29
  31. package/src/components/editor/DomEditCropHandles.tsx +238 -0
  32. package/src/components/editor/DomEditOverlay.tsx +111 -108
  33. package/src/components/editor/DomEditRotateHandle.tsx +41 -0
  34. package/src/components/editor/PropertyPanel.test.ts +48 -0
  35. package/src/components/editor/PropertyPanel.tsx +27 -47
  36. package/src/components/editor/PropertyPanelEmptyState.tsx +33 -0
  37. package/src/components/editor/SnapToolbar.tsx +20 -1
  38. package/src/components/editor/clipPathHelpers.ts +113 -0
  39. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  40. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  41. package/src/components/editor/domEditOverlayCrop.test.ts +106 -0
  42. package/src/components/editor/domEditOverlayCrop.ts +115 -0
  43. package/src/components/editor/domEditOverlayGeometry.ts +14 -0
  44. package/src/components/editor/domEditOverlayShape.ts +39 -0
  45. package/src/components/editor/domEditing.test.ts +2 -0
  46. package/src/components/editor/domEditingTypes.ts +3 -0
  47. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  48. package/src/components/editor/manualEditingAvailability.ts +0 -6
  49. package/src/components/editor/marqueeCommit.ts +3 -2
  50. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  51. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  52. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  53. package/src/components/editor/propertyPanelHelpers.ts +32 -125
  54. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  55. package/src/components/editor/propertyPanelStyleSections.tsx +75 -1
  56. package/src/components/editor/propertyPanelTypes.ts +113 -0
  57. package/src/components/editor/snapTargetCollection.ts +2 -3
  58. package/src/components/editor/useDomEditCompositionRect.ts +68 -0
  59. package/src/components/editor/useDomEditOverlayGestures.ts +13 -6
  60. package/src/components/editor/useDomEditOverlayRects.ts +5 -3
  61. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  62. package/src/components/nle/NLELayout.tsx +54 -51
  63. package/src/components/nle/NLEPreview.tsx +41 -5
  64. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  65. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  66. package/src/components/renders/RenderQueue.tsx +125 -24
  67. package/src/components/renders/RenderQueueItem.tsx +132 -64
  68. package/src/components/renders/useRenderQueue.ts +136 -24
  69. package/src/components/sidebar/AssetsTab.test.ts +87 -0
  70. package/src/components/sidebar/AssetsTab.tsx +113 -15
  71. package/src/components/sidebar/BlocksTab.tsx +2 -1
  72. package/src/components/sidebar/GlobalAssetsView.tsx +91 -0
  73. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  74. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  75. package/src/components/storyboard/StoryboardView.tsx +6 -0
  76. package/src/components/studioColorGradingScope.ts +124 -0
  77. package/src/components/studioMediaJobs.ts +123 -0
  78. package/src/components/ui/Button.tsx +20 -11
  79. package/src/components/ui/HyperframesLoader.tsx +9 -2
  80. package/src/components/ui/SearchInput.tsx +53 -0
  81. package/src/components/ui/Tooltip.tsx +52 -6
  82. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  83. package/src/components/ui/useDialogBehavior.ts +83 -0
  84. package/src/contexts/PanelLayoutContext.tsx +3 -0
  85. package/src/contexts/StudioContext.tsx +7 -0
  86. package/src/hooks/domSelectionTestHarness.ts +1 -0
  87. package/src/hooks/useCompositionContentLoader.ts +40 -0
  88. package/src/hooks/useCropMode.ts +91 -0
  89. package/src/hooks/useDomEditCommits.test.tsx +2 -0
  90. package/src/hooks/useEditorSave.ts +14 -0
  91. package/src/hooks/useFileManager.ts +39 -25
  92. package/src/hooks/useFrameCapture.ts +12 -1
  93. package/src/hooks/usePanelLayout.ts +1 -0
  94. package/src/hooks/usePreviewPersistence.ts +24 -15
  95. package/src/hooks/useStudioContextValue.ts +5 -0
  96. package/src/hooks/useToast.ts +66 -13
  97. package/src/player/components/ShortcutsPanel.tsx +9 -0
  98. package/src/player/store/playerStore.ts +14 -0
  99. package/src/styles/studio.css +61 -0
  100. package/src/utils/sdkResolverShadow.test.ts +53 -3
  101. package/src/utils/sdkResolverShadow.ts +41 -1
  102. package/src/utils/studioPendingEdits.test.ts +43 -0
  103. package/src/utils/studioPendingEdits.ts +45 -0
  104. package/src/utils/studioUiPreferences.ts +4 -0
  105. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  106. package/dist/assets/index-DmkOvZns.css +0 -1
  107. package/dist/assets/index-SYSbQyK4.js +0 -396
  108. package/dist/chunk-JND3XUJL.js.map +0 -1
  109. /package/dist/{domEditingLayers-UIQZJCOA.js.map → domEditingLayers-S6YOLVRG.js.map} +0 -0
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  useCallback,
3
+ useEffect,
3
4
  useMemo,
4
5
  useRef,
5
6
  useState,
@@ -27,6 +28,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
27
28
  import { useFileManagerContext } from "../contexts/FileManagerContext";
28
29
  import { useDomEditContext } from "../contexts/DomEditContext";
29
30
  import { usePlayerStore } from "../player";
31
+ import { waitForMediaJob } from "./studioMediaJobs";
32
+ import {
33
+ applyColorGradingScopeUpdate,
34
+ EMPTY_COLOR_GRADING_SCOPE_RESULT,
35
+ type ColorGradingScope,
36
+ } from "./studioColorGradingScope";
37
+ import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";
30
38
 
31
39
  const MIN_INSPECTOR_SPLIT_PERCENT = 20;
32
40
  const MAX_INSPECTOR_SPLIT_PERCENT = 75;
@@ -43,6 +51,8 @@ export interface StudioRightPanelProps {
43
51
  recordingState?: "idle" | "recording" | "preview";
44
52
  recordingDuration?: number;
45
53
  onToggleRecording?: () => void;
54
+ cropMode?: boolean;
55
+ onCropModeChange?: (active: boolean) => void;
46
56
  /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */
47
57
  sdkSession: Composition | null;
48
58
  reloadPreview: () => void;
@@ -62,6 +72,8 @@ export function StudioRightPanel({
62
72
  recordingState,
63
73
  recordingDuration,
64
74
  onToggleRecording,
75
+ cropMode,
76
+ onCropModeChange,
65
77
  sdkSession,
66
78
  reloadPreview,
67
79
  domEditSaveTimestampRef,
@@ -69,6 +81,7 @@ export function StudioRightPanel({
69
81
  }: StudioRightPanelProps) {
70
82
  const {
71
83
  rightWidth,
84
+ setRightWidth,
72
85
  rightPanelTab,
73
86
  setRightPanelTab,
74
87
  rightInspectorPanes,
@@ -82,6 +95,7 @@ export function StudioRightPanel({
82
95
  previewIframeRef,
83
96
  projectId,
84
97
  activeCompPath,
98
+ showToast,
85
99
  compositionDimensions,
86
100
  waitForPendingDomEditSaves,
87
101
  renderQueue,
@@ -136,8 +150,10 @@ export function StudioRightPanel({
136
150
  projectDir,
137
151
  handleImportFiles,
138
152
  handleImportFonts,
153
+ refreshFileTree,
139
154
  readProjectFile,
140
155
  writeProjectFile,
156
+ fileTree,
141
157
  } = useFileManagerContext();
142
158
 
143
159
  // Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
@@ -172,6 +188,14 @@ export function StudioRightPanel({
172
188
  startPercent: number;
173
189
  height: number;
174
190
  } | null>(null);
191
+ const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
192
+
193
+ useEffect(
194
+ () => () => {
195
+ backgroundRemovalAbortRef.current?.abort();
196
+ },
197
+ [],
198
+ );
175
199
 
176
200
  const renderJobs = renderQueue.jobs as RenderJob[];
177
201
  const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -233,6 +257,90 @@ export function StudioRightPanel({
233
257
  splitDragRef.current = null;
234
258
  }, []);
235
259
 
260
+ const handleApplyColorGradingScope = useCallback(
261
+ async (scope: ColorGradingScope, value: string | null) =>
262
+ applyColorGradingScopeUpdate({
263
+ scope,
264
+ value,
265
+ selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
266
+ fileTree,
267
+ projectId,
268
+ domEditSaveTimestampRef,
269
+ waitForPendingDomEditSaves,
270
+ readProjectFile,
271
+ writeProjectFile,
272
+ recordEdit,
273
+ reloadPreview,
274
+ showToast,
275
+ }).catch((error) => {
276
+ showToast(
277
+ `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
278
+ "error",
279
+ );
280
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
281
+ }),
282
+ [
283
+ activeCompPath,
284
+ domEditSaveTimestampRef,
285
+ domEditSelection?.sourceFile,
286
+ fileTree,
287
+ projectId,
288
+ readProjectFile,
289
+ recordEdit,
290
+ reloadPreview,
291
+ showToast,
292
+ waitForPendingDomEditSaves,
293
+ writeProjectFile,
294
+ ],
295
+ );
296
+
297
+ const handleRemoveBackground = useCallback(
298
+ // fallow-ignore-next-line complexity
299
+ async (
300
+ inputPath: string,
301
+ options: {
302
+ createBackgroundPlate?: boolean;
303
+ quality?: "fast" | "balanced" | "best";
304
+ onProgress?: (progress: BackgroundRemovalProgress) => void;
305
+ },
306
+ ) => {
307
+ const response = await fetch(
308
+ `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
309
+ {
310
+ method: "POST",
311
+ headers: { "Content-Type": "application/json" },
312
+ body: JSON.stringify({
313
+ inputPath,
314
+ createBackgroundPlate: options.createBackgroundPlate === true,
315
+ quality: options.quality ?? "balanced",
316
+ }),
317
+ },
318
+ );
319
+ const data = (await response.json().catch(() => ({}))) as {
320
+ jobId?: string;
321
+ error?: string;
322
+ };
323
+ if (!response.ok || !data.jobId) {
324
+ throw new Error(data.error || `Background removal failed (${response.status})`);
325
+ }
326
+ showToast("Removing background...", "info");
327
+ backgroundRemovalAbortRef.current?.abort();
328
+ const controller = new AbortController();
329
+ backgroundRemovalAbortRef.current = controller;
330
+ try {
331
+ const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
332
+ await refreshFileTree();
333
+ showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
334
+ return result;
335
+ } finally {
336
+ if (backgroundRemovalAbortRef.current === controller) {
337
+ backgroundRemovalAbortRef.current = null;
338
+ }
339
+ }
340
+ },
341
+ [projectId, refreshFileTree, showToast],
342
+ );
343
+
236
344
  const propertyPanel = (
237
345
  <PropertyPanel
238
346
  projectId={projectId}
@@ -246,7 +354,9 @@ export function StudioRightPanel({
246
354
  onSetStyle={handleDomStyleCommit}
247
355
  onSetAttribute={handleDomAttributeCommit}
248
356
  onSetAttributeLive={handleDomAttributeLiveCommit}
357
+ onApplyColorGradingScope={handleApplyColorGradingScope}
249
358
  onSetHtmlAttribute={handleDomHtmlAttributeCommit}
359
+ onRemoveBackground={handleRemoveBackground}
250
360
  onSetManualOffset={handleDomPathOffsetCommit}
251
361
  onSetManualSize={handleDomBoxSizeCommit}
252
362
  onSetManualRotation={handleDomRotationCommit}
@@ -287,6 +397,8 @@ export function StudioRightPanel({
287
397
  recordingState={recordingState}
288
398
  recordingDuration={recordingDuration}
289
399
  onToggleRecording={onToggleRecording}
400
+ cropMode={cropMode}
401
+ onCropModeChange={onCropModeChange}
290
402
  />
291
403
  );
292
404
 
@@ -295,6 +407,11 @@ export function StudioRightPanel({
295
407
  jobs={renderJobs}
296
408
  projectId={projectId}
297
409
  onDelete={renderQueue.deleteRender}
410
+ onCancel={renderQueue.cancelRender}
411
+ loadError={renderQueue.loadError}
412
+ onRetryLoad={renderQueue.reloadRenders}
413
+ actionError={renderQueue.actionError}
414
+ onDismissActionError={renderQueue.dismissActionError}
298
415
  onClearCompleted={renderQueue.clearCompleted}
299
416
  onStartRender={async (format, quality, resolution, fps) => {
300
417
  await waitForPendingDomEditSaves();
@@ -316,30 +433,43 @@ export function StudioRightPanel({
316
433
  return (
317
434
  <>
318
435
  <div
319
- className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
436
+ role="separator"
437
+ aria-label="Resize inspector panel"
438
+ aria-orientation="vertical"
439
+ tabIndex={0}
440
+ className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
320
441
  style={{ touchAction: "none" }}
321
442
  onPointerDown={(e) => handlePanelResizeStart("right", e)}
322
443
  onPointerMove={handlePanelResizeMove}
323
444
  onPointerUp={handlePanelResizeEnd}
445
+ onPointerCancel={handlePanelResizeEnd}
446
+ onKeyDown={(e) => {
447
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
448
+ e.preventDefault();
449
+ // Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
450
+ const delta = e.key === "ArrowLeft" ? 16 : -16;
451
+ setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
452
+ }}
324
453
  >
325
454
  <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
326
455
  </div>
327
456
  <div
328
- className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
457
+ className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
329
458
  style={{ width: rightWidth }}
330
459
  >
331
460
  {captionEditMode ? (
332
461
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
333
462
  ) : (
334
463
  <>
335
- <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
464
+ <div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
336
465
  {STUDIO_INSPECTOR_PANELS_ENABLED && (
337
466
  <>
338
467
  <Tooltip label="Element styles and properties" side="bottom">
339
468
  <button
340
469
  type="button"
341
470
  onClick={() => handleInspectorPaneButtonClick("design")}
342
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
471
+ aria-pressed={designPaneOpen}
472
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
343
473
  designPaneOpen
344
474
  ? "bg-neutral-800 text-white"
345
475
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -352,7 +482,8 @@ export function StudioRightPanel({
352
482
  <button
353
483
  type="button"
354
484
  onClick={() => handleInspectorPaneButtonClick("layers")}
355
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
485
+ aria-pressed={layersPaneOpen}
486
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
356
487
  layersPaneOpen
357
488
  ? "bg-neutral-800 text-white"
358
489
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -367,7 +498,8 @@ export function StudioRightPanel({
367
498
  <button
368
499
  type="button"
369
500
  onClick={() => setRightPanelTab("renders")}
370
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
501
+ aria-pressed={rightPanelTab === "renders"}
502
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
371
503
  rightPanelTab === "renders"
372
504
  ? "bg-neutral-800 text-white"
373
505
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -380,7 +512,8 @@ export function StudioRightPanel({
380
512
  <button
381
513
  type="button"
382
514
  onClick={() => setRightPanelTab("slideshow")}
383
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
515
+ aria-pressed={rightPanelTab === "slideshow"}
516
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
384
517
  rightPanelTab === "slideshow"
385
518
  ? "bg-neutral-800 text-white"
386
519
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -390,7 +523,7 @@ export function StudioRightPanel({
390
523
  </button>
391
524
  </Tooltip>
392
525
  </div>
393
- <div className="min-h-0 flex-1">
526
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
394
527
  {rightPanelTab === "block-params" && activeBlockParams ? (
395
528
  <BlockParamsPanel
396
529
  blockName={activeBlockParams.blockName}
@@ -406,7 +539,7 @@ export function StudioRightPanel({
406
539
  onPersistNotes={onPersistSlideshowNotes}
407
540
  />
408
541
  ) : layersPaneOpen && designPaneOpen ? (
409
- <div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
542
+ <div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
410
543
  <div
411
544
  className="min-h-[120px] overflow-hidden"
412
545
  style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
@@ -432,6 +565,24 @@ export function StudioRightPanel({
432
565
  <LayersPanel />
433
566
  ) : designPaneOpen ? (
434
567
  propertyPanel
568
+ ) : inspectorTabActive ? (
569
+ // Inspector tab selected but no pane can render (panes toggled
570
+ // off, or inspector inactive during playback/recording): show an
571
+ // explanation instead of silently rendering the render queue
572
+ // under a highlighted inspector tab.
573
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
574
+ <p className="text-xs text-neutral-500">
575
+ Inspector is unavailable right now — select the Design or Layers pane above, or
576
+ pause playback/recording to inspect elements.
577
+ </p>
578
+ <button
579
+ type="button"
580
+ onClick={() => setRightPanelTab("renders")}
581
+ className="h-7 rounded-md border border-neutral-800 px-3 text-[11px] font-medium text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200 active:scale-[0.98]"
582
+ >
583
+ Show Renders
584
+ </button>
585
+ </div>
435
586
  ) : (
436
587
  renderQueuePanel
437
588
  )}
@@ -2,15 +2,18 @@ export function StudioSplash({ waiting }: { waiting?: boolean }) {
2
2
  return (
3
3
  <div className="h-full w-full bg-neutral-950 flex items-center justify-center">
4
4
  {waiting ? (
5
- <div className="flex flex-col items-center gap-3 text-center px-6">
6
- <div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin" />
5
+ <div className="flex flex-col items-center gap-3 text-center px-6" role="status">
6
+ <div className="w-4 h-4 rounded-full border-2 border-neutral-700 border-t-neutral-500 animate-spin motion-reduce:animate-none" />
7
7
  <p className="text-xs text-neutral-600">
8
8
  Waiting for preview server… run{" "}
9
9
  <code className="text-neutral-500 font-mono">npm run dev</code>
10
10
  </p>
11
11
  </div>
12
12
  ) : (
13
- <div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse" />
13
+ <div className="flex flex-col items-center gap-3 text-center px-6" role="status">
14
+ <div className="w-4 h-4 rounded-full bg-studio-accent animate-pulse motion-reduce:animate-none" />
15
+ <p className="text-xs text-neutral-600">Connecting to project…</p>
16
+ </div>
14
17
  )}
15
18
  </div>
16
19
  );
@@ -1,20 +1,20 @@
1
1
  interface StudioToastProps {
2
2
  message: string;
3
3
  tone?: "error" | "info";
4
+ /** Plays the exit animation when true (owner removes the node after ~160ms). */
5
+ leaving?: boolean;
4
6
  onDismiss?: () => void;
5
7
  }
6
8
 
7
- export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
9
+ export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastProps) {
8
10
  const isError = tone === "error";
9
11
  return (
10
12
  <div
11
- className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
12
- onClick={onDismiss}
13
- role={onDismiss ? "button" : undefined}
14
- style={onDismiss ? { cursor: "pointer" } : undefined}
13
+ role={isError ? "alert" : "status"}
14
+ className={`motion-reduce:animate-none ${leaving ? "hf-toast-exit" : "hf-toast-enter"}`}
15
15
  >
16
16
  <div
17
- className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
17
+ className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
18
18
  style={{
19
19
  background: isError
20
20
  ? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
@@ -29,14 +29,15 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
29
29
  ].join(", "),
30
30
  }}
31
31
  >
32
- <span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
32
+ <span
33
+ className={`min-w-0 break-words leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
34
+ >
35
+ {message}
36
+ </span>
33
37
  {onDismiss && (
34
38
  <button
35
39
  type="button"
36
- onClick={(e) => {
37
- e.stopPropagation();
38
- onDismiss();
39
- }}
40
+ onClick={onDismiss}
40
41
  className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
41
42
  aria-label="Dismiss"
42
43
  >
@@ -30,21 +30,27 @@ function renderToolbar() {
30
30
  describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => {
31
31
  it("renders enabled (pressed) by default with no selection", () => {
32
32
  const { host, root } = renderToolbar();
33
- const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]');
33
+ const btn = host.querySelector<HTMLButtonElement>(
34
+ 'button[aria-label="Auto-record manual edits as keyframes"]',
35
+ );
34
36
  expect(btn).not.toBeNull();
37
+ expect(btn?.getAttribute("aria-pressed")).toBe("true");
35
38
  act(() => root.unmount());
36
39
  });
37
40
 
38
41
  it("flips autoKeyframeEnabled in the store when clicked", () => {
39
42
  const { host, root } = renderToolbar();
40
- const btn = host.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')!;
43
+ const btn = host.querySelector<HTMLButtonElement>(
44
+ 'button[aria-label="Auto-record manual edits as keyframes"]',
45
+ );
46
+ if (!btn) throw new Error("auto-keyframe toggle not rendered");
41
47
 
42
48
  act(() => {
43
49
  btn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
44
50
  });
45
51
 
46
52
  expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false);
47
- expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull();
53
+ expect(btn.getAttribute("aria-pressed")).toBe("false");
48
54
  act(() => root.unmount());
49
55
  });
50
56
  });
@@ -112,7 +112,9 @@ export function TimelineToolbar({
112
112
  <button
113
113
  type="button"
114
114
  onClick={() => setActiveTool("select")}
115
- className={`flex h-6 w-6 items-center justify-center transition-colors ${
115
+ aria-label="Selection tool"
116
+ aria-pressed={activeTool === "select"}
117
+ className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
116
118
  activeTool === "select"
117
119
  ? "bg-neutral-700 text-neutral-200"
118
120
  : "text-neutral-500 hover:text-neutral-300"
@@ -123,11 +125,13 @@ export function TimelineToolbar({
123
125
  </svg>
124
126
  </button>
125
127
  </Tooltip>
126
- <Tooltip label="Razor tool (B)">
128
+ <Tooltip label="Razor tool (B) — Shift+click splits all tracks">
127
129
  <button
128
130
  type="button"
129
131
  onClick={() => setActiveTool("razor")}
130
- className={`flex h-6 w-6 items-center justify-center transition-colors ${
132
+ aria-label="Razor tool"
133
+ aria-pressed={activeTool === "razor"}
134
+ className={`flex h-6 w-6 items-center justify-center transition-colors active:scale-[0.98] ${
131
135
  activeTool === "razor"
132
136
  ? "bg-neutral-700 text-neutral-200"
133
137
  : "text-neutral-500 hover:text-neutral-300"
@@ -153,7 +157,12 @@ export function TimelineToolbar({
153
157
  <button
154
158
  type="button"
155
159
  onClick={onToggleKeyframe}
156
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
160
+ aria-label={
161
+ keyframeState === "active"
162
+ ? "Remove keyframe at playhead"
163
+ : "Add keyframe at playhead"
164
+ }
165
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
157
166
  keyframeState === "active"
158
167
  ? "text-studio-accent"
159
168
  : keyframeState === "inactive"
@@ -187,8 +196,9 @@ export function TimelineToolbar({
187
196
  <button
188
197
  type="button"
189
198
  onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
199
+ aria-label="Auto-record manual edits as keyframes"
190
200
  aria-pressed={autoKeyframeEnabled}
191
- className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
201
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors active:scale-[0.98] ${
192
202
  autoKeyframeEnabled
193
203
  ? "text-red-400 hover:text-red-300"
194
204
  : "text-neutral-600 hover:text-neutral-400"
@@ -213,23 +223,35 @@ export function TimelineToolbar({
213
223
  )}
214
224
  {onSplitElement &&
215
225
  (() => {
226
+ // Render the button unconditionally (disabled when unusable):
227
+ // mounting/unmounting mid-task shifts the neighboring controls.
216
228
  const { selectedElementId, elements, currentTime } = usePlayerStore.getState();
217
229
  const el = selectedElementId
218
230
  ? elements.find((e) => (e.key ?? e.id) === selectedElementId)
219
231
  : null;
220
- if (!el || !canSplitElement(el)) return null;
221
- const canSplit = currentTime > el.start && currentTime < el.start + el.duration;
232
+ const splittable = el != null && canSplitElement(el);
233
+ const canSplit =
234
+ splittable && currentTime > el.start && currentTime < el.start + el.duration;
222
235
  return (
223
- <Tooltip label="Split clip at playhead (S)">
236
+ <Tooltip
237
+ label={
238
+ canSplit
239
+ ? "Split clip at playhead (S)"
240
+ : splittable
241
+ ? "Move the playhead inside the clip to split"
242
+ : "Select a clip to split"
243
+ }
244
+ >
224
245
  <button
225
246
  type="button"
226
247
  disabled={!canSplit}
248
+ aria-label="Split clip at playhead"
227
249
  onClick={() => {
228
- if (canSplit) onSplitElement(el, currentTime);
250
+ if (canSplit && el) onSplitElement(el, currentTime);
229
251
  }}
230
252
  className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
231
253
  canSplit
232
- ? "text-neutral-500 hover:text-neutral-200"
254
+ ? "text-neutral-500 hover:text-neutral-200 active:scale-[0.98]"
233
255
  : "text-neutral-700 cursor-not-allowed"
234
256
  }`}
235
257
  >
@@ -239,26 +261,38 @@ export function TimelineToolbar({
239
261
  );
240
262
  })()}
241
263
  {beatAnalysisReady &&
242
- canAddBeatAt(currentTime) &&
243
- (() => (
244
- <Tooltip label="Add beat at playhead">
245
- <button
246
- type="button"
247
- onClick={() => addBeatAtCompositionTime(currentTime)}
248
- className="flex h-7 w-7 items-center justify-center rounded text-neutral-500 transition-colors hover:text-[#22c55e]"
264
+ (() => {
265
+ const canAdd = canAddBeatAt(currentTime);
266
+ return (
267
+ <Tooltip
268
+ label={canAdd ? "Add beat at playhead" : "A beat already exists at the playhead"}
249
269
  >
250
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
251
- <path
252
- d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
253
- stroke="currentColor"
254
- strokeWidth="2"
255
- strokeLinecap="round"
256
- strokeLinejoin="round"
257
- />
258
- </svg>
259
- </button>
260
- </Tooltip>
261
- ))()}
270
+ <button
271
+ type="button"
272
+ disabled={!canAdd}
273
+ aria-label="Add beat at playhead"
274
+ onClick={() => {
275
+ if (canAdd) addBeatAtCompositionTime(currentTime);
276
+ }}
277
+ className={`flex h-7 w-7 items-center justify-center rounded transition-colors ${
278
+ canAdd
279
+ ? "text-neutral-500 hover:text-[#22c55e] active:scale-[0.98]"
280
+ : "text-neutral-700 cursor-not-allowed"
281
+ }`}
282
+ >
283
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none">
284
+ <path
285
+ d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
286
+ stroke="currentColor"
287
+ strokeWidth="2"
288
+ strokeLinecap="round"
289
+ strokeLinejoin="round"
290
+ />
291
+ </svg>
292
+ </button>
293
+ </Tooltip>
294
+ );
295
+ })()}
262
296
  </div>
263
297
  <div className="flex items-center gap-1">
264
298
  <Tooltip label="Fit timeline to width">