@hyperframes/studio 0.7.37 → 0.7.38

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 (75) hide show
  1. package/dist/assets/hyperframes-player-BBrKzTGd.js +459 -0
  2. package/dist/assets/index-BLRTwY5l.js +396 -0
  3. package/dist/assets/{index-BXYJbIax.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-Ct-X2wlf.js → index-Ykq7ihge.js} +1 -1
  6. package/dist/index.d.ts +35 -10
  7. package/dist/index.html +2 -2
  8. package/dist/index.js +5146 -3321
  9. package/dist/index.js.map +1 -1
  10. package/package.json +7 -7
  11. package/src/App.tsx +19 -18
  12. package/src/components/AskAgentModal.tsx +23 -6
  13. package/src/components/LintModal.tsx +40 -10
  14. package/src/components/MediaPreview.tsx +36 -1
  15. package/src/components/SaveQueuePausedBanner.tsx +7 -6
  16. package/src/components/StudioErrorBoundary.tsx +19 -31
  17. package/src/components/StudioFeedbackBar.tsx +11 -2
  18. package/src/components/StudioGlobalDragOverlay.tsx +1 -1
  19. package/src/components/StudioHeader.tsx +184 -106
  20. package/src/components/StudioLeftSidebar.tsx +24 -3
  21. package/src/components/StudioOverlays.tsx +34 -8
  22. package/src/components/StudioRightPanel.tsx +154 -9
  23. package/src/components/StudioSplash.tsx +6 -3
  24. package/src/components/StudioToast.tsx +12 -11
  25. package/src/components/TimelineToolbar.test.tsx +9 -3
  26. package/src/components/TimelineToolbar.tsx +63 -29
  27. package/src/components/editor/PropertyPanel.tsx +18 -17
  28. package/src/components/editor/colorGradingScopePatch.test.ts +74 -0
  29. package/src/components/editor/colorGradingScopePatch.ts +57 -0
  30. package/src/components/editor/manualEditingAvailability.test.ts +0 -12
  31. package/src/components/editor/manualEditingAvailability.ts +0 -6
  32. package/src/components/editor/propertyPanelColorGradingControls.tsx +371 -329
  33. package/src/components/editor/propertyPanelColorGradingSection.tsx +274 -55
  34. package/src/components/editor/propertyPanelColorGradingSlider.tsx +275 -0
  35. package/src/components/editor/propertyPanelHelpers.ts +22 -91
  36. package/src/components/editor/propertyPanelMediaSection.tsx +253 -99
  37. package/src/components/editor/propertyPanelTypes.ts +111 -0
  38. package/src/components/nle/CompositionBreadcrumb.tsx +3 -2
  39. package/src/components/nle/NLELayout.tsx +54 -51
  40. package/src/components/nle/NLEPreview.tsx +41 -5
  41. package/src/components/nle/TimelineResizeDivider.tsx +97 -0
  42. package/src/components/nle/usePreviewBlockDrop.ts +31 -7
  43. package/src/components/renders/RenderQueue.tsx +125 -24
  44. package/src/components/renders/RenderQueueItem.tsx +132 -64
  45. package/src/components/renders/useRenderQueue.ts +136 -24
  46. package/src/components/storyboard/StoryboardFrameFocus.tsx +39 -16
  47. package/src/components/storyboard/StoryboardLoaded.tsx +19 -2
  48. package/src/components/storyboard/StoryboardView.tsx +6 -0
  49. package/src/components/studioColorGradingScope.ts +124 -0
  50. package/src/components/studioMediaJobs.ts +123 -0
  51. package/src/components/ui/Button.tsx +20 -11
  52. package/src/components/ui/HyperframesLoader.tsx +9 -2
  53. package/src/components/ui/SearchInput.tsx +53 -0
  54. package/src/components/ui/Tooltip.tsx +52 -6
  55. package/src/components/ui/VideoFrameThumbnail.tsx +26 -3
  56. package/src/components/ui/useDialogBehavior.ts +83 -0
  57. package/src/contexts/PanelLayoutContext.tsx +3 -0
  58. package/src/contexts/StudioContext.tsx +7 -0
  59. package/src/hooks/useCompositionContentLoader.ts +40 -0
  60. package/src/hooks/useEditorSave.ts +14 -0
  61. package/src/hooks/useFileManager.ts +39 -25
  62. package/src/hooks/useFrameCapture.ts +12 -1
  63. package/src/hooks/usePanelLayout.ts +1 -0
  64. package/src/hooks/usePreviewPersistence.ts +24 -15
  65. package/src/hooks/useStudioContextValue.ts +5 -0
  66. package/src/hooks/useToast.ts +66 -13
  67. package/src/styles/studio.css +61 -0
  68. package/src/utils/sdkResolverShadow.test.ts +53 -3
  69. package/src/utils/sdkResolverShadow.ts +41 -1
  70. package/src/utils/studioPendingEdits.test.ts +43 -0
  71. package/src/utils/studioPendingEdits.ts +45 -0
  72. package/src/utils/studioUiPreferences.ts +4 -0
  73. package/dist/assets/hyperframes-player-BGuumSiM.js +0 -459
  74. package/dist/assets/index-DmkOvZns.css +0 -1
  75. package/dist/assets/index-SYSbQyK4.js +0 -396
@@ -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;
@@ -69,6 +77,7 @@ export function StudioRightPanel({
69
77
  }: StudioRightPanelProps) {
70
78
  const {
71
79
  rightWidth,
80
+ setRightWidth,
72
81
  rightPanelTab,
73
82
  setRightPanelTab,
74
83
  rightInspectorPanes,
@@ -82,6 +91,7 @@ export function StudioRightPanel({
82
91
  previewIframeRef,
83
92
  projectId,
84
93
  activeCompPath,
94
+ showToast,
85
95
  compositionDimensions,
86
96
  waitForPendingDomEditSaves,
87
97
  renderQueue,
@@ -136,8 +146,10 @@ export function StudioRightPanel({
136
146
  projectDir,
137
147
  handleImportFiles,
138
148
  handleImportFonts,
149
+ refreshFileTree,
139
150
  readProjectFile,
140
151
  writeProjectFile,
152
+ fileTree,
141
153
  } = useFileManagerContext();
142
154
 
143
155
  // Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
@@ -172,6 +184,14 @@ export function StudioRightPanel({
172
184
  startPercent: number;
173
185
  height: number;
174
186
  } | null>(null);
187
+ const backgroundRemovalAbortRef = useRef<AbortController | null>(null);
188
+
189
+ useEffect(
190
+ () => () => {
191
+ backgroundRemovalAbortRef.current?.abort();
192
+ },
193
+ [],
194
+ );
175
195
 
176
196
  const renderJobs = renderQueue.jobs as RenderJob[];
177
197
  const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
@@ -233,6 +253,90 @@ export function StudioRightPanel({
233
253
  splitDragRef.current = null;
234
254
  }, []);
235
255
 
256
+ const handleApplyColorGradingScope = useCallback(
257
+ async (scope: ColorGradingScope, value: string | null) =>
258
+ applyColorGradingScopeUpdate({
259
+ scope,
260
+ value,
261
+ selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
262
+ fileTree,
263
+ projectId,
264
+ domEditSaveTimestampRef,
265
+ waitForPendingDomEditSaves,
266
+ readProjectFile,
267
+ writeProjectFile,
268
+ recordEdit,
269
+ reloadPreview,
270
+ showToast,
271
+ }).catch((error) => {
272
+ showToast(
273
+ `Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
274
+ "error",
275
+ );
276
+ return EMPTY_COLOR_GRADING_SCOPE_RESULT;
277
+ }),
278
+ [
279
+ activeCompPath,
280
+ domEditSaveTimestampRef,
281
+ domEditSelection?.sourceFile,
282
+ fileTree,
283
+ projectId,
284
+ readProjectFile,
285
+ recordEdit,
286
+ reloadPreview,
287
+ showToast,
288
+ waitForPendingDomEditSaves,
289
+ writeProjectFile,
290
+ ],
291
+ );
292
+
293
+ const handleRemoveBackground = useCallback(
294
+ // fallow-ignore-next-line complexity
295
+ async (
296
+ inputPath: string,
297
+ options: {
298
+ createBackgroundPlate?: boolean;
299
+ quality?: "fast" | "balanced" | "best";
300
+ onProgress?: (progress: BackgroundRemovalProgress) => void;
301
+ },
302
+ ) => {
303
+ const response = await fetch(
304
+ `/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
305
+ {
306
+ method: "POST",
307
+ headers: { "Content-Type": "application/json" },
308
+ body: JSON.stringify({
309
+ inputPath,
310
+ createBackgroundPlate: options.createBackgroundPlate === true,
311
+ quality: options.quality ?? "balanced",
312
+ }),
313
+ },
314
+ );
315
+ const data = (await response.json().catch(() => ({}))) as {
316
+ jobId?: string;
317
+ error?: string;
318
+ };
319
+ if (!response.ok || !data.jobId) {
320
+ throw new Error(data.error || `Background removal failed (${response.status})`);
321
+ }
322
+ showToast("Removing background...", "info");
323
+ backgroundRemovalAbortRef.current?.abort();
324
+ const controller = new AbortController();
325
+ backgroundRemovalAbortRef.current = controller;
326
+ try {
327
+ const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
328
+ await refreshFileTree();
329
+ showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
330
+ return result;
331
+ } finally {
332
+ if (backgroundRemovalAbortRef.current === controller) {
333
+ backgroundRemovalAbortRef.current = null;
334
+ }
335
+ }
336
+ },
337
+ [projectId, refreshFileTree, showToast],
338
+ );
339
+
236
340
  const propertyPanel = (
237
341
  <PropertyPanel
238
342
  projectId={projectId}
@@ -246,7 +350,9 @@ export function StudioRightPanel({
246
350
  onSetStyle={handleDomStyleCommit}
247
351
  onSetAttribute={handleDomAttributeCommit}
248
352
  onSetAttributeLive={handleDomAttributeLiveCommit}
353
+ onApplyColorGradingScope={handleApplyColorGradingScope}
249
354
  onSetHtmlAttribute={handleDomHtmlAttributeCommit}
355
+ onRemoveBackground={handleRemoveBackground}
250
356
  onSetManualOffset={handleDomPathOffsetCommit}
251
357
  onSetManualSize={handleDomBoxSizeCommit}
252
358
  onSetManualRotation={handleDomRotationCommit}
@@ -295,6 +401,11 @@ export function StudioRightPanel({
295
401
  jobs={renderJobs}
296
402
  projectId={projectId}
297
403
  onDelete={renderQueue.deleteRender}
404
+ onCancel={renderQueue.cancelRender}
405
+ loadError={renderQueue.loadError}
406
+ onRetryLoad={renderQueue.reloadRenders}
407
+ actionError={renderQueue.actionError}
408
+ onDismissActionError={renderQueue.dismissActionError}
298
409
  onClearCompleted={renderQueue.clearCompleted}
299
410
  onStartRender={async (format, quality, resolution, fps) => {
300
411
  await waitForPendingDomEditSaves();
@@ -316,30 +427,43 @@ export function StudioRightPanel({
316
427
  return (
317
428
  <>
318
429
  <div
319
- className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
430
+ role="separator"
431
+ aria-label="Resize inspector panel"
432
+ aria-orientation="vertical"
433
+ tabIndex={0}
434
+ className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center outline-none focus-visible:bg-studio-accent/20"
320
435
  style={{ touchAction: "none" }}
321
436
  onPointerDown={(e) => handlePanelResizeStart("right", e)}
322
437
  onPointerMove={handlePanelResizeMove}
323
438
  onPointerUp={handlePanelResizeEnd}
439
+ onPointerCancel={handlePanelResizeEnd}
440
+ onKeyDown={(e) => {
441
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
442
+ e.preventDefault();
443
+ // Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it.
444
+ const delta = e.key === "ArrowLeft" ? 16 : -16;
445
+ setRightWidth(Math.max(160, Math.min(600, rightWidth + delta)));
446
+ }}
324
447
  >
325
448
  <div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
326
449
  </div>
327
450
  <div
328
- className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
451
+ className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
329
452
  style={{ width: rightWidth }}
330
453
  >
331
454
  {captionEditMode ? (
332
455
  <CaptionPropertyPanel iframeRef={previewIframeRef} />
333
456
  ) : (
334
457
  <>
335
- <div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
458
+ <div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
336
459
  {STUDIO_INSPECTOR_PANELS_ENABLED && (
337
460
  <>
338
461
  <Tooltip label="Element styles and properties" side="bottom">
339
462
  <button
340
463
  type="button"
341
464
  onClick={() => handleInspectorPaneButtonClick("design")}
342
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
465
+ aria-pressed={designPaneOpen}
466
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
343
467
  designPaneOpen
344
468
  ? "bg-neutral-800 text-white"
345
469
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -352,7 +476,8 @@ export function StudioRightPanel({
352
476
  <button
353
477
  type="button"
354
478
  onClick={() => handleInspectorPaneButtonClick("layers")}
355
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
479
+ aria-pressed={layersPaneOpen}
480
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
356
481
  layersPaneOpen
357
482
  ? "bg-neutral-800 text-white"
358
483
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -367,7 +492,8 @@ export function StudioRightPanel({
367
492
  <button
368
493
  type="button"
369
494
  onClick={() => setRightPanelTab("renders")}
370
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
495
+ aria-pressed={rightPanelTab === "renders"}
496
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
371
497
  rightPanelTab === "renders"
372
498
  ? "bg-neutral-800 text-white"
373
499
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -380,7 +506,8 @@ export function StudioRightPanel({
380
506
  <button
381
507
  type="button"
382
508
  onClick={() => setRightPanelTab("slideshow")}
383
- className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors ${
509
+ aria-pressed={rightPanelTab === "slideshow"}
510
+ className={`h-8 rounded-xl px-3 text-[11px] font-medium transition-colors active:scale-[0.98] ${
384
511
  rightPanelTab === "slideshow"
385
512
  ? "bg-neutral-800 text-white"
386
513
  : "text-neutral-500 hover:bg-neutral-800/70 hover:text-neutral-200"
@@ -390,7 +517,7 @@ export function StudioRightPanel({
390
517
  </button>
391
518
  </Tooltip>
392
519
  </div>
393
- <div className="min-h-0 flex-1">
520
+ <div className="min-h-0 min-w-0 flex-1 overflow-hidden">
394
521
  {rightPanelTab === "block-params" && activeBlockParams ? (
395
522
  <BlockParamsPanel
396
523
  blockName={activeBlockParams.blockName}
@@ -406,7 +533,7 @@ export function StudioRightPanel({
406
533
  onPersistNotes={onPersistSlideshowNotes}
407
534
  />
408
535
  ) : layersPaneOpen && designPaneOpen ? (
409
- <div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
536
+ <div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
410
537
  <div
411
538
  className="min-h-[120px] overflow-hidden"
412
539
  style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
@@ -432,6 +559,24 @@ export function StudioRightPanel({
432
559
  <LayersPanel />
433
560
  ) : designPaneOpen ? (
434
561
  propertyPanel
562
+ ) : inspectorTabActive ? (
563
+ // Inspector tab selected but no pane can render (panes toggled
564
+ // off, or inspector inactive during playback/recording): show an
565
+ // explanation instead of silently rendering the render queue
566
+ // under a highlighted inspector tab.
567
+ <div className="flex h-full flex-col items-center justify-center gap-3 px-6 text-center">
568
+ <p className="text-xs text-neutral-500">
569
+ Inspector is unavailable right now — select the Design or Layers pane above, or
570
+ pause playback/recording to inspect elements.
571
+ </p>
572
+ <button
573
+ type="button"
574
+ onClick={() => setRightPanelTab("renders")}
575
+ 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]"
576
+ >
577
+ Show Renders
578
+ </button>
579
+ </div>
435
580
  ) : (
436
581
  renderQueuePanel
437
582
  )}
@@ -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">
@@ -22,11 +22,7 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
22
22
  import { GsapAnimationSection } from "./GsapAnimationSection";
23
23
  import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
24
24
  import { KeyframeNavigation } from "./KeyframeNavigation";
25
- import {
26
- STUDIO_COLOR_GRADING_ENABLED,
27
- STUDIO_GSAP_PANEL_ENABLED,
28
- STUDIO_KEYFRAMES_ENABLED,
29
- } from "./manualEditingAvailability";
25
+ import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
30
26
  import { usePlayerStore, liveTime } from "../../player";
31
27
  import { TimingSection } from "./propertyPanelTimingSection";
32
28
  import { type PropertyPanelProps } from "./propertyPanelHelpers";
@@ -57,7 +53,9 @@ export const PropertyPanel = memo(function PropertyPanel({
57
53
  onSetStyle,
58
54
  onSetAttribute,
59
55
  onSetAttributeLive,
56
+ onApplyColorGradingScope,
60
57
  onSetHtmlAttribute,
58
+ onRemoveBackground,
61
59
  onSetManualOffset,
62
60
  onSetManualSize,
63
61
  onSetManualRotation,
@@ -352,18 +350,7 @@ export const PropertyPanel = memo(function PropertyPanel({
352
350
  onSetAttribute={onSetAttribute}
353
351
  />
354
352
  )}
355
- {sections.media && (
356
- <MediaSection
357
- projectDir={projectDir}
358
- element={element}
359
- styles={styles}
360
- onSetStyle={onSetStyle}
361
- onSetAttribute={onSetAttribute}
362
- onSetHtmlAttribute={onSetHtmlAttribute}
363
- />
364
- )}
365
-
366
- {STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
353
+ {sections.colorGrading && (
367
354
  <ColorGradingSection
368
355
  key={[
369
356
  element.id ?? "",
@@ -371,11 +358,25 @@ export const PropertyPanel = memo(function PropertyPanel({
371
358
  element.selector ?? "",
372
359
  String(element.selectorIndex ?? ""),
373
360
  ].join("|")}
361
+ projectId={projectId}
374
362
  element={element}
375
363
  assets={assets}
376
364
  previewIframeRef={previewIframeRef}
377
365
  onImportAssets={onImportAssets}
378
366
  onSetAttributeLive={onSetAttributeLive}
367
+ onApplyScope={onApplyColorGradingScope}
368
+ />
369
+ )}
370
+
371
+ {sections.media && (
372
+ <MediaSection
373
+ projectDir={projectDir}
374
+ element={element}
375
+ styles={styles}
376
+ onSetStyle={onSetStyle}
377
+ onSetAttribute={onSetAttribute}
378
+ onSetHtmlAttribute={onSetHtmlAttribute}
379
+ onRemoveBackground={onRemoveBackground}
379
380
  />
380
381
  )}
381
382