@hyperframes/studio 0.8.52 → 0.8.53

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-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
  2. package/dist/assets/index-DrHQAuGO.css +1 -0
  3. package/dist/assets/index-HfP3sap2.js +390 -0
  4. package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
  5. package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
  6. package/dist/index.css +29 -0
  7. package/dist/index.css.map +1 -0
  8. package/dist/index.d.ts +13 -6
  9. package/dist/index.html +2 -2
  10. package/dist/index.js +7664 -7953
  11. package/dist/index.js.map +1 -1
  12. package/dist/styles.css +1 -0
  13. package/package.json +8 -7
  14. package/src/App.tsx +42 -48
  15. package/src/components/EditorShell.selectionSync.test.tsx +9 -4
  16. package/src/components/EditorShell.tsx +52 -56
  17. package/src/components/StudioHeader.dom.test.tsx +1 -1
  18. package/src/components/StudioHeader.test.ts +0 -12
  19. package/src/components/StudioHeader.tsx +10 -13
  20. package/src/components/StudioLeftPanels.tsx +202 -0
  21. package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
  22. package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
  23. package/src/components/dock/Dock.test.tsx +16 -2
  24. package/src/components/dock/Dock.tsx +13 -3
  25. package/src/components/dock/dockLayoutStore.test.ts +28 -0
  26. package/src/components/dock/dockLayoutStore.ts +21 -4
  27. package/src/components/editor/SnapToolbar.test.tsx +0 -3
  28. package/src/components/nle/NLEContext.tsx +0 -43
  29. package/src/components/nle/TimelinePane.tsx +46 -67
  30. package/src/components/panels/SlideshowPanel.tsx +1 -1
  31. package/src/components/panels/VariablesPanel.tsx +1 -1
  32. package/src/components/renders/RenderQueue.test.tsx +133 -25
  33. package/src/components/renders/RenderQueue.tsx +99 -92
  34. package/src/components/renders/RenderQueueItem.tsx +69 -64
  35. package/src/components/renders/RenderQueuePanel.tsx +1 -1
  36. package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
  37. package/src/components/sidebar/CompositionsPanel.tsx +15 -0
  38. package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
  39. package/src/components/sidebar/SidebarLintButton.tsx +50 -0
  40. package/src/components/ui/Select.tsx +4 -0
  41. package/src/contexts/PanelLayoutContext.tsx +3 -57
  42. package/src/headerExports.test.tsx +23 -0
  43. package/src/hooks/appHotkeysDispatch.ts +3 -4
  44. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
  45. package/src/hooks/useAppHotkeys.test.ts +0 -1
  46. package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
  47. package/src/hooks/useAppHotkeys.ts +0 -4
  48. package/src/hooks/useDomEditPreviewSync.ts +4 -6
  49. package/src/hooks/useDomEditSession.ts +0 -7
  50. package/src/hooks/useDomEditWiring.ts +4 -9
  51. package/src/hooks/usePanelLayout.test.ts +108 -296
  52. package/src/hooks/usePanelLayout.ts +50 -255
  53. package/src/hooks/useRightPanelIntents.test.ts +181 -0
  54. package/src/hooks/useRightPanelIntents.ts +64 -0
  55. package/src/hooks/useSlideshowTabState.test.ts +9 -31
  56. package/src/hooks/useSlideshowTabState.ts +6 -19
  57. package/src/hooks/useStudioContextValue.test.ts +6 -18
  58. package/src/hooks/useStudioContextValue.ts +5 -26
  59. package/src/index.ts +2 -1
  60. package/src/main.tsx +26 -0
  61. package/src/player/components/Timeline.tsx +1 -1
  62. package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
  63. package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
  64. package/src/utils/studioHelpers.ts +0 -7
  65. package/src/utils/studioUiPreferences.test.ts +10 -16
  66. package/src/utils/studioUiPreferences.ts +0 -16
  67. package/dist/assets/index-CxAu_Okg.css +0 -1
  68. package/dist/assets/index-DocJlW2v.js +0 -376
  69. package/src/components/StudioLeftSidebar.tsx +0 -207
  70. package/src/components/nle/TimelineResizeDivider.tsx +0 -101
  71. package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
  72. package/src/components/sidebar/LeftSidebar.tsx +0 -348
  73. package/src/hooks/useInspectorSplitResize.ts +0 -51
  74. package/src/utils/fitPanels.test.ts +0 -164
  75. package/src/utils/fitPanels.ts +0 -151
@@ -5,6 +5,9 @@ import { RenderQueueItem } from "./RenderQueueItem";
5
5
  import { FfmpegRequiredNotice } from "./FfmpegRequiredNotice";
6
6
  import type { FfmpegStatus } from "./useFfmpegStatus";
7
7
  import { Button } from "../ui/Button";
8
+ import { IconButton } from "../ui/IconButton";
9
+ import { Select, type SelectOption } from "../ui/Select";
10
+ import { Tooltip } from "../ui/Tooltip";
8
11
  import { resolveFloatingPanelPosition, type FloatingPosition } from "../editor/floatingPanel";
9
12
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
10
13
  import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
@@ -15,7 +18,7 @@ export interface CompositionDimensions {
15
18
  height: number;
16
19
  }
17
20
 
18
- type StartRenderHandler = (
21
+ export type StartRenderHandler = (
19
22
  format: "mp4" | "webm" | "mov",
20
23
  quality: "draft" | "standard" | "high",
21
24
  resolution: ResolutionPreset | "auto",
@@ -207,7 +210,7 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
207
210
  onFocus={show}
208
211
  onBlur={hide}
209
212
  onClick={() => setOpen((prev) => !prev)}
210
- className="flex items-center justify-center p-0.5 -m-0.5 rounded-sm text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent"
213
+ className="flex items-center justify-center p-0.5 -m-0.5 rounded-sm text-text-5 hover:text-text-3 transition-colors cursor-help outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent"
211
214
  >
212
215
  <svg
213
216
  width="12"
@@ -232,17 +235,17 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
232
235
  role="tooltip"
233
236
  onPointerEnter={show}
234
237
  onPointerLeave={hide}
235
- className="fixed w-52 p-2 rounded-sm bg-panel-input border border-neutral-700 shadow-lg z-200"
238
+ className="fixed w-52 p-2 rounded-sm bg-input border border-border-input shadow-menu z-200"
236
239
  style={{ left: position?.left ?? -9999, top: position?.top ?? -9999 }}
237
240
  >
238
- <p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
239
- <p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
240
- <div className="mt-1.5 pt-1.5 border-t border-neutral-800">
241
+ <p className="text-step-10 font-semibold text-text-1 mb-0.5">{info.label}</p>
242
+ <p className="text-step-9 text-text-3 leading-tight">{info.desc}</p>
243
+ <div className="mt-1.5 pt-1.5 border-t border-border">
241
244
  {(["mp4", "mov", "webm"] as const)
242
245
  .filter((f) => f !== format)
243
246
  .map((f) => (
244
- <p key={f} className="text-[9px] text-panel-text-4 leading-relaxed">
245
- <span className="text-panel-text-3 font-medium">{FORMAT_INFO[f].label}</span>
247
+ <p key={f} className="text-step-9 text-text-4 leading-relaxed">
248
+ <span className="text-text-3 font-medium">{FORMAT_INFO[f].label}</span>
246
249
  {" — "}
247
250
  {FORMAT_INFO[f].desc}
248
251
  </p>
@@ -255,14 +258,22 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
255
258
  );
256
259
  }
257
260
 
258
- const QUALITY_OPTIONS: {
259
- value: "draft" | "standard" | "high";
260
- label: string;
261
- title: string;
262
- }[] = [
263
- { value: "draft", label: "Draft", title: "Fast render, smaller file" },
264
- { value: "standard", label: "Standard", title: "Good quality, balanced file size" },
265
- { value: "high", label: "High Quality", title: "Best quality, larger file" },
261
+ const FORMAT_OPTIONS: SelectOption[] = [
262
+ { value: "mp4", label: "MP4" },
263
+ { value: "mov", label: "MOV (ProRes)" },
264
+ { value: "webm", label: "WebM" },
265
+ ];
266
+
267
+ const QUALITY_OPTIONS: SelectOption[] = [
268
+ { value: "draft", label: "Draft" },
269
+ { value: "standard", label: "Standard" },
270
+ { value: "high", label: "High Quality" },
271
+ ];
272
+
273
+ const FPS_OPTIONS: SelectOption[] = [
274
+ { value: "24", label: "24 fps" },
275
+ { value: "30", label: "30 fps" },
276
+ { value: "60", label: "60 fps" },
266
277
  ];
267
278
 
268
279
  function formatEta(ms: number): string {
@@ -302,9 +313,6 @@ function FormatExportButton({
302
313
  // MOV (ProRes) is a fixed-quality codec — quality selector has no effect.
303
314
  const showQuality = format !== "mov";
304
315
 
305
- const selectCls =
306
- "h-7 w-full px-2 text-[11px] bg-panel-input rounded-md text-panel-text-1 outline-hidden cursor-pointer disabled:opacity-50 hover:bg-panel-hover transition-colors";
307
-
308
316
  return (
309
317
  <div className="flex flex-col gap-3">
310
318
  {missingFfmpeg && (
@@ -317,85 +325,70 @@ function FormatExportButton({
317
325
  <div className="grid grid-cols-2 gap-2">
318
326
  <div className="flex flex-col gap-1">
319
327
  <div className="flex items-center gap-1">
320
- <span className="text-[10px] text-panel-text-4">Format</span>
328
+ <span className="text-step-10 text-text-4">Format</span>
321
329
  <FormatInfoTooltip format={format} />
322
330
  </div>
323
- <select
331
+ <Select
332
+ label="Format"
324
333
  value={format}
325
- onChange={(e) => {
326
- const v = e.target.value as "mp4" | "webm" | "mov";
334
+ options={FORMAT_OPTIONS}
335
+ disabled={isRendering}
336
+ onCommit={(next) => {
337
+ const v = next as "mp4" | "webm" | "mov";
327
338
  setFormat(v);
328
339
  persistRenderSettings(v, quality, fps);
329
340
  }}
330
- disabled={isRendering}
331
- className={selectCls}
332
- >
333
- <option value="mp4">MP4</option>
334
- <option value="mov">MOV (ProRes)</option>
335
- <option value="webm">WebM</option>
336
- </select>
341
+ />
337
342
  </div>
338
343
  <div className="flex flex-col gap-1">
339
- <span className="text-[10px] text-panel-text-4">Resolution</span>
340
- <select
344
+ <span className="text-step-10 text-text-4">Resolution</span>
345
+ <Select
346
+ label="Resolution"
341
347
  value={resolution}
342
- onChange={(e) => setResolution(e.target.value as RenderScale)}
348
+ options={SCALE_OPTION_ORDER.map((value) => ({
349
+ value,
350
+ label: scaleOptionLabel(value, compositionDimensions),
351
+ disabled: !scaleApplies(value, compositionDimensions),
352
+ }))}
343
353
  disabled={isRendering}
344
- className={selectCls}
345
- >
346
- {SCALE_OPTION_ORDER.map((value) => (
347
- <option
348
- key={value}
349
- value={value}
350
- disabled={!scaleApplies(value, compositionDimensions)}
351
- >
352
- {scaleOptionLabel(value, compositionDimensions)}
353
- </option>
354
- ))}
355
- </select>
354
+ onCommit={(next) => setResolution(next as RenderScale)}
355
+ />
356
356
  </div>
357
357
  <div className="flex flex-col gap-1">
358
- <span className="text-[10px] text-panel-text-4">Frame rate</span>
359
- <select
360
- value={fps}
361
- onChange={(e) => {
362
- const v = Number(e.target.value) as 24 | 30 | 60;
358
+ <span className="text-step-10 text-text-4">Frame rate</span>
359
+ <Select
360
+ label="Frame rate"
361
+ value={String(fps)}
362
+ options={FPS_OPTIONS}
363
+ disabled={isRendering}
364
+ onCommit={(next) => {
365
+ const v = Number(next) as 24 | 30 | 60;
363
366
  setFps(v);
364
367
  persistRenderSettings(format, quality, v);
365
368
  }}
366
- disabled={isRendering}
367
- className={selectCls}
368
- >
369
- <option value={24}>24 fps</option>
370
- <option value={30}>30 fps</option>
371
- <option value={60}>60 fps</option>
372
- </select>
369
+ />
373
370
  </div>
374
371
  {showQuality && (
375
372
  <div className="flex flex-col gap-1">
376
- <span className="text-[10px] text-panel-text-4">Quality</span>
377
- <select
373
+ <span className="text-step-10 text-text-4">Quality</span>
374
+ <Select
375
+ label="Quality"
378
376
  value={quality}
379
- onChange={(e) => {
380
- const v = e.target.value as "draft" | "standard" | "high";
377
+ options={QUALITY_OPTIONS}
378
+ disabled={isRendering}
379
+ onCommit={(next) => {
380
+ const v = next as "draft" | "standard" | "high";
381
381
  setQuality(v);
382
382
  persistRenderSettings(format, v, fps);
383
383
  }}
384
- disabled={isRendering}
385
- className={selectCls}
386
- >
387
- {QUALITY_OPTIONS.map((q) => (
388
- <option key={q.value} value={q.value}>
389
- {q.label}
390
- </option>
391
- ))}
392
- </select>
384
+ />
393
385
  </div>
394
386
  )}
395
387
  </div>
396
388
  <Button
397
389
  variant="primary"
398
390
  size="md"
391
+ data-testid="renders-export"
399
392
  loading={isRendering}
400
393
  disabled={missingFfmpeg !== null}
401
394
  title={missingFfmpeg ? "Install FFmpeg to export. See the note above." : undefined}
@@ -407,12 +400,15 @@ function FormatExportButton({
407
400
  trackStudioEvent("render_start", { format, quality, resolution: outputResolution, fps });
408
401
  void onStartRender(format, quality, outputResolution, fps);
409
402
  }}
410
- className="w-full font-semibold"
403
+ // Width only. A type size or a weight here would win the merge against
404
+ // the size recipe and leave this Export a step away from the header's
405
+ // (AE3), which is exactly what it used to do.
406
+ className="w-full"
411
407
  >
412
408
  {isRendering ? "Rendering…" : "Export"}
413
409
  </Button>
414
410
  {lastRenderDurationMs !== undefined && !isRendering && (
415
- <p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
411
+ <p className="text-step-9 text-text-5 text-center -mt-1.5">
416
412
  Last render took {formatEta(lastRenderDurationMs)}
417
413
  </p>
418
414
  )}
@@ -454,7 +450,7 @@ export const RenderQueue = memo(function RenderQueue({
454
450
 
455
451
  return (
456
452
  <div className="flex flex-col h-full">
457
- <div className="px-3 py-3 border-b border-panel-border shrink-0">
453
+ <div className="px-3 py-3 border-b border-border shrink-0">
458
454
  <FormatExportButton
459
455
  onStartRender={onStartRender}
460
456
  isRendering={isRendering}
@@ -469,17 +465,30 @@ export const RenderQueue = memo(function RenderQueue({
469
465
  {actionError && (
470
466
  <div
471
467
  role="alert"
472
- className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
468
+ className="flex items-start justify-between gap-2 px-3 py-2 border-b border-border bg-danger/10"
473
469
  >
474
- <span className="text-[10px] text-red-400">{actionError}</span>
470
+ <span className="text-step-10 text-danger">{actionError}</span>
475
471
  {onDismissActionError && (
476
- <button
472
+ <IconButton
473
+ size="sm"
477
474
  onClick={onDismissActionError}
478
475
  aria-label="Dismiss error"
479
- className="text-[10px] text-panel-text-4 hover:text-panel-text-2 shrink-0"
480
- >
481
- ✕
482
- </button>
476
+ className="shrink-0"
477
+ icon={
478
+ <svg
479
+ width="10"
480
+ height="10"
481
+ viewBox="0 0 24 24"
482
+ fill="none"
483
+ stroke="currentColor"
484
+ strokeWidth="2"
485
+ strokeLinecap="round"
486
+ aria-hidden="true"
487
+ >
488
+ <path d="M18 6L6 18M6 6l12 12" />
489
+ </svg>
490
+ }
491
+ />
483
492
  )}
484
493
  </div>
485
494
  )}
@@ -488,7 +497,7 @@ export const RenderQueue = memo(function RenderQueue({
488
497
  <div ref={listRef} className="flex-1 overflow-y-auto">
489
498
  {loadError && jobs.length === 0 ? (
490
499
  <div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
491
- <p className="text-[10px] text-red-400 text-center">{loadError}</p>
500
+ <p className="text-step-10 text-danger text-center">{loadError}</p>
492
501
  {onRetryLoad && (
493
502
  <Button size="sm" variant="secondary" onClick={onRetryLoad}>
494
503
  Retry
@@ -504,7 +513,7 @@ export const RenderQueue = memo(function RenderQueue({
504
513
  fill="none"
505
514
  stroke="currentColor"
506
515
  strokeWidth="1.5"
507
- className="text-panel-text-5"
516
+ className="text-text-5"
508
517
  >
509
518
  <rect
510
519
  x="2"
@@ -522,24 +531,22 @@ export const RenderQueue = memo(function RenderQueue({
522
531
  strokeLinejoin="round"
523
532
  />
524
533
  </svg>
525
- <p className="text-[10px] text-panel-text-5 text-center">No renders yet</p>
534
+ <p className="text-step-10 text-text-5 text-center">No renders yet</p>
526
535
  </div>
527
536
  ) : (
528
537
  <div>
529
538
  {completedCount > 0 && (
530
- <div className="flex items-center justify-between px-3 py-1.5 border-b border-panel-border">
531
- <span className="text-[10px] text-panel-text-4">
539
+ <div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
540
+ <span className="text-step-10 text-text-4">
532
541
  {jobs.length} render{jobs.length === 1 ? "" : "s"}
533
542
  </span>
534
543
  {/* "Hide", not "Clear": files stay on disk (delete is per-row
535
544
  and confirmed); hidden rows don't resurrect on reload. */}
536
- <button
537
- onClick={onClearCompleted}
538
- title="Hide finished renders from this list (files stay on disk)"
539
- className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
540
- >
541
- Hide finished
542
- </button>
545
+ <Tooltip label="Hide finished renders from this list (files stay on disk)">
546
+ <Button size="sm" variant="ghost" onClick={onClearCompleted}>
547
+ Hide finished
548
+ </Button>
549
+ </Tooltip>
543
550
  </div>
544
551
  )}
545
552
  {jobs.map((job) => (
@@ -2,6 +2,8 @@ import { buildProjectApiPath } from "../../utils/projectRouting";
2
2
  import { memo, useCallback, useState } from "react";
3
3
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
4
4
  import { Button } from "../ui/Button";
5
+ import { IconButton } from "../ui/IconButton";
6
+ import { Tooltip } from "../ui/Tooltip";
5
7
  import type { RenderJob } from "./useRenderQueue";
6
8
 
7
9
  interface RenderQueueItemProps {
@@ -66,7 +68,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
66
68
  setVideoReady(false);
67
69
  setConfirmingDelete(false);
68
70
  }}
69
- className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
71
+ className="px-3 py-2.5 border-b border-border last:border-0 transition-colors duration-150 hover:bg-hover/30"
70
72
  >
71
73
  <div className="flex items-center gap-2.5">
72
74
  {/* Thumbnail — static frame; swaps to live video on hover.
@@ -77,8 +79,8 @@ export const RenderQueueItem = memo(function RenderQueueItem({
77
79
  disabled={!isComplete}
78
80
  aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
79
81
  className={[
80
- "w-20 h-[45px] rounded-md overflow-hidden bg-panel-input shrink-0 relative",
81
- "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent",
82
+ "w-20 h-[45px] rounded-md overflow-hidden bg-input shrink-0 relative",
83
+ "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent",
82
84
  isComplete ? "cursor-pointer" : "cursor-default",
83
85
  ].join(" ")}
84
86
  >
@@ -108,17 +110,17 @@ export const RenderQueueItem = memo(function RenderQueueItem({
108
110
  )}
109
111
  {isRendering && (
110
112
  <div className="w-full h-full flex items-center justify-center">
111
- <div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
113
+ <div className="w-2 h-2 rounded-full bg-accent animate-pulse motion-reduce:animate-none" />
112
114
  </div>
113
115
  )}
114
116
  {job.status === "failed" && (
115
117
  <div className="w-full h-full flex items-center justify-center">
116
- <div className="w-2 h-2 rounded-full bg-red-400" />
118
+ <div className="w-2 h-2 rounded-full bg-danger" />
117
119
  </div>
118
120
  )}
119
121
  {job.status === "cancelled" && (
120
122
  <div className="w-full h-full flex items-center justify-center">
121
- <div className="w-2 h-2 rounded-full bg-neutral-600" />
123
+ <div className="w-2 h-2 rounded-full bg-text-5" />
122
124
  </div>
123
125
  )}
124
126
  </button>
@@ -126,11 +128,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({
126
128
  {/* Info */}
127
129
  <div className="flex-1 min-w-0">
128
130
  <div className="flex items-center gap-1.5">
129
- <span className="text-[11px] font-medium text-panel-text-2 truncate">
130
- {job.filename}
131
- </span>
131
+ <span className="text-step-11 font-medium text-text-2 truncate">{job.filename}</span>
132
132
  {job.durationMs && (
133
- <span className="text-[9px] text-panel-text-5 shrink-0">
133
+ <span className="text-step-9 text-text-5 shrink-0">
134
134
  {formatDuration(job.durationMs)}
135
135
  </span>
136
136
  )}
@@ -139,11 +139,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
139
139
  {isRendering && (
140
140
  <div className="mt-1">
141
141
  <div className="flex items-center justify-between mb-0.5">
142
- <span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
143
- <span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
142
+ <span className="text-step-9 text-text-4">{job.stage || "Rendering"}</span>
143
+ <span className="text-step-9 font-mono text-accent">{job.progress}%</span>
144
144
  </div>
145
145
  <div
146
- className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
146
+ className="w-full h-1 bg-border rounded-full overflow-hidden"
147
147
  role="progressbar"
148
148
  aria-valuenow={job.progress}
149
149
  aria-valuemin={0}
@@ -151,7 +151,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
151
151
  aria-label={`Render progress: ${job.progress}%`}
152
152
  >
153
153
  <div
154
- className="h-full bg-panel-accent rounded-full transition-all duration-300"
154
+ className="h-full bg-accent rounded-full transition-all duration-300"
155
155
  style={{ width: `${job.progress}%` }}
156
156
  />
157
157
  </div>
@@ -159,14 +159,14 @@ export const RenderQueueItem = memo(function RenderQueueItem({
159
159
  )}
160
160
 
161
161
  {job.status === "failed" && job.error && (
162
- <span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
162
+ <span className="text-step-9 text-danger mt-0.5 block">{job.error}</span>
163
163
  )}
164
164
  {job.status === "cancelled" && (
165
- <span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
165
+ <span className="text-step-9 text-text-4 mt-0.5 block">Cancelled</span>
166
166
  )}
167
167
 
168
168
  {!isRendering && (
169
- <span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
169
+ <span className="text-step-9 text-text-5">{formatTimeAgo(job.createdAt)}</span>
170
170
  )}
171
171
  </div>
172
172
 
@@ -209,53 +209,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({
209
209
  </>
210
210
  ) : (
211
211
  <>
212
- <button
213
- onClick={isComplete ? handleDownload : undefined}
214
- className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent ${
215
- isComplete
216
- ? "text-panel-text-5 hover:text-panel-accent"
217
- : "text-panel-text-5/30 cursor-default"
218
- }`}
219
- title={isComplete ? "Download" : undefined}
220
- aria-label={`Download ${job.filename}`}
221
- disabled={!isComplete}
222
- >
223
- <svg
224
- width="12"
225
- height="12"
226
- viewBox="0 0 24 24"
227
- fill="none"
228
- stroke="currentColor"
229
- strokeWidth="2"
230
- strokeLinecap="round"
231
- strokeLinejoin="round"
232
- >
233
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
234
- <polyline points="7 10 12 15 17 10" />
235
- <line x1="12" y1="15" x2="12" y2="3" />
236
- </svg>
237
- </button>
238
- <button
239
- onClick={(e) => {
240
- e.stopPropagation();
241
- setConfirmingDelete(true);
242
- }}
243
- className="p-1.5 min-w-6 min-h-6 rounded-sm text-panel-text-5 hover:text-red-400 transition-colors outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent"
244
- title="Delete render file"
245
- aria-label={`Delete ${job.filename}`}
246
- >
247
- <svg
248
- width="12"
249
- height="12"
250
- viewBox="0 0 24 24"
251
- fill="none"
252
- stroke="currentColor"
253
- strokeWidth="2"
254
- strokeLinecap="round"
255
- >
256
- <path d="M18 6L6 18M6 6l12 12" />
257
- </svg>
258
- </button>
212
+ {/* The tooltip explains the control whether or not it is
213
+ reachable; `disabled:` on the shared Button keeps pointer
214
+ events alive precisely so a disabled one can still say why. */}
215
+ <Tooltip label={isComplete ? "Download" : "Available when the render finishes"}>
216
+ <IconButton
217
+ size="sm"
218
+ onClick={handleDownload}
219
+ disabled={!isComplete}
220
+ aria-label={`Download ${job.filename}`}
221
+ icon={
222
+ <svg
223
+ width="12"
224
+ height="12"
225
+ viewBox="0 0 24 24"
226
+ fill="none"
227
+ stroke="currentColor"
228
+ strokeWidth="2"
229
+ strokeLinecap="round"
230
+ strokeLinejoin="round"
231
+ aria-hidden="true"
232
+ >
233
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
234
+ <polyline points="7 10 12 15 17 10" />
235
+ <line x1="12" y1="15" x2="12" y2="3" />
236
+ </svg>
237
+ }
238
+ />
239
+ </Tooltip>
240
+ <Tooltip label="Delete render file">
241
+ <IconButton
242
+ size="sm"
243
+ onClick={(e) => {
244
+ e.stopPropagation();
245
+ setConfirmingDelete(true);
246
+ }}
247
+ aria-label={`Delete ${job.filename}`}
248
+ icon={
249
+ <svg
250
+ width="12"
251
+ height="12"
252
+ viewBox="0 0 24 24"
253
+ fill="none"
254
+ stroke="currentColor"
255
+ strokeWidth="2"
256
+ strokeLinecap="round"
257
+ aria-hidden="true"
258
+ >
259
+ <path d="M18 6L6 18M6 6l12 12" />
260
+ </svg>
261
+ }
262
+ />
263
+ </Tooltip>
259
264
  </>
260
265
  )}
261
266
  </div>
@@ -7,7 +7,7 @@ import { usePreviewVariablesStore } from "../../hooks/previewVariablesStore";
7
7
  /**
8
8
  * The Renders tab, wired to the shell context.
9
9
  *
10
- * Split out of StudioRightPanel because every field it needs already lives in
10
+ * Split out of StudioRightPanels because every field it needs already lives in
11
11
  * that context, so routing them through the panel only made the panel longer
12
12
  * without giving anything a second reader.
13
13
  */
@@ -4,7 +4,7 @@ import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { usePlayerStore } from "../../player/store/playerStore";
7
- import { LeftSidebar } from "./LeftSidebar";
7
+ import { CompositionsPanel } from "./CompositionsPanel";
8
8
 
9
9
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
10
10
  (
@@ -20,23 +20,20 @@ afterEach(() => {
20
20
  usePlayerStore.setState({ thumbnailContentRevision: 0 });
21
21
  });
22
22
 
23
- describe("LeftSidebar root badge source", () => {
23
+ describe("CompositionsPanel root badge source", () => {
24
24
  it("derives the root badge from the filtered compositions list, not the raw file tree", () => {
25
- // `fileTree` includes a non-composition index.html (a vendored preset) that the
26
- // server's filtered `compositions` list excludes — the badge must source from
27
- // `compositions`, not the raw file tree.
25
+ // The project's raw file tree can hold a non-composition index.html (a vendored
26
+ // preset); the badge must source from the server's filtered `compositions` list.
28
27
  const host = document.createElement("div");
29
28
  document.body.append(host);
30
29
  root = createRoot(host);
31
30
  act(() => {
32
31
  root?.render(
33
- <LeftSidebar
32
+ <CompositionsPanel
34
33
  projectId="demo"
35
34
  compositions={["hero.html"]}
36
- assets={[]}
37
- fileTree={["index.html", "hero.html"]}
38
35
  activeComposition={null}
39
- onSelectComposition={vi.fn()}
36
+ onSelect={vi.fn()}
40
37
  />,
41
38
  );
42
39
  });
@@ -0,0 +1,15 @@
1
+ import type { ComponentProps } from "react";
2
+ import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
3
+ import { CompositionsTab } from "./CompositionsTab";
4
+
5
+ type Props = Omit<ComponentProps<typeof CompositionsTab>, "masterCompositionPath">;
6
+
7
+ /** The Compositions dock panel body; the root badge comes from the filtered composition list. */
8
+ export function CompositionsPanel(props: Props) {
9
+ return (
10
+ <CompositionsTab
11
+ {...props}
12
+ masterCompositionPath={resolveMasterCompositionPath(props.compositions)}
13
+ />
14
+ );
15
+ }
@@ -3,7 +3,7 @@
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
- import { LeftSidebar } from "./LeftSidebar";
6
+ import { SidebarLintButton } from "./SidebarLintButton";
7
7
 
8
8
  Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
9
9
  (
@@ -24,23 +24,18 @@ function renderBadge(lintHasError: boolean): HTMLElement | null {
24
24
  root = createRoot(host);
25
25
  act(() => {
26
26
  root?.render(
27
- <LeftSidebar
28
- projectId="demo"
29
- compositions={["index.html"]}
30
- assets={[]}
31
- fileTree={["index.html"]}
32
- activeComposition={null}
33
- onSelectComposition={vi.fn()}
27
+ <SidebarLintButton
34
28
  onLint={vi.fn()}
35
- lintFindingCount={2}
36
- lintHasError={lintHasError}
29
+ linting={false}
30
+ findingCount={2}
31
+ hasError={lintHasError}
37
32
  />,
38
33
  );
39
34
  });
40
35
  return host.querySelector<HTMLElement>("[data-lint-badge]");
41
36
  }
42
37
 
43
- describe("LeftSidebar lint badge", () => {
38
+ describe("SidebarLintButton badge", () => {
44
39
  it("pulses, and stops for reduced motion, while an error finding exists", () => {
45
40
  const badge = renderBadge(true);
46
41
  expect(badge?.dataset.lintBadge).toBe("error");