@hyperframes/studio 0.7.36 → 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-BltxqwG6.js → index-ClUipc8i.js} +1 -1
  4. package/dist/assets/index-DJaiR8T2.css +1 -0
  5. package/dist/assets/{index-_bjyggFK.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 +5147 -3322
  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 +70 -3
  69. package/src/utils/sdkResolverShadow.ts +47 -4
  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-DfeE1_Rl.js +0 -396
  75. package/dist/assets/index-DmkOvZns.css +0 -1
@@ -1,5 +1,6 @@
1
- import { memo, useState, useRef, useEffect } from "react";
1
+ import { memo, useState, useRef, useEffect, useId } from "react";
2
2
  import { RenderQueueItem } from "./RenderQueueItem";
3
+ import { Button } from "../ui/Button";
3
4
  import type { RenderJob, ResolutionPreset } from "./useRenderQueue";
4
5
  import { getPersistedRenderSettings, persistRenderSettings } from "./renderSettings";
5
6
  import { trackStudioEvent } from "../../utils/studioTelemetry";
@@ -20,9 +21,17 @@ interface RenderQueueProps {
20
21
  jobs: RenderJob[];
21
22
  projectId: string;
22
23
  onDelete: (jobId: string) => void;
24
+ onCancel?: (jobId: string) => void;
23
25
  onClearCompleted: () => void;
24
26
  onStartRender: StartRenderHandler;
25
27
  isRendering: boolean;
28
+ /** History fetch failure (null when the last load succeeded). */
29
+ loadError?: string | null;
30
+ /** Retry a failed history load. */
31
+ onRetryLoad?: () => void;
32
+ /** Failure of a delete/cancel action, shown inline until dismissed. */
33
+ actionError?: string | null;
34
+ onDismissActionError?: () => void;
26
35
  /**
27
36
  * Authored dimensions of the active composition. Used to pick the
28
37
  * matching preset (landscape / portrait / square) when the user selects
@@ -110,9 +119,15 @@ function scaleOptionLabel(
110
119
  dims: CompositionDimensions | null | undefined,
111
120
  ): string {
112
121
  const resolved = resolvedDimensions(scale, dims);
113
- return resolved
122
+ const base = resolved
114
123
  ? `${SCALE_LABEL[scale]} · ${resolved.width}×${resolved.height}`
115
124
  : SCALE_LABEL[scale];
125
+ // Explain *why* an option is disabled instead of greying it silently:
126
+ // the preset must be an exact integer upscale of the authored size.
127
+ if (dims && !scaleApplies(scale, dims)) {
128
+ return `${base} — not an integer scale of ${dims.width}×${dims.height}`;
129
+ }
130
+ return base;
116
131
  }
117
132
 
118
133
  const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string }> = {
@@ -127,9 +142,14 @@ const FORMAT_INFO: Record<"mp4" | "webm" | "mov", { label: string; desc: string
127
142
  },
128
143
  };
129
144
 
145
+ // Rich format guidance in a keyboard-reachable disclosure: the trigger is a
146
+ // real button (focusable, labelled), the panel is tied to it via
147
+ // aria-describedby, and Escape dismisses (WCAG 1.4.13). Content is too rich
148
+ // for the one-line ui/Tooltip primitive, so this stays a local popover.
130
149
  function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
131
150
  const [open, setOpen] = useState(false);
132
151
  const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
152
+ const panelId = useId();
133
153
 
134
154
  const show = () => {
135
155
  clearTimeout(timeoutRef.current);
@@ -141,27 +161,51 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) {
141
161
 
142
162
  useEffect(() => () => clearTimeout(timeoutRef.current), []);
143
163
 
164
+ useEffect(() => {
165
+ if (!open) return;
166
+ const onKeyDown = (e: KeyboardEvent) => {
167
+ if (e.key === "Escape") setOpen(false);
168
+ };
169
+ document.addEventListener("keydown", onKeyDown);
170
+ return () => document.removeEventListener("keydown", onKeyDown);
171
+ }, [open]);
172
+
144
173
  const info = FORMAT_INFO[format];
145
174
 
146
175
  return (
147
176
  <div className="relative" onPointerEnter={show} onPointerLeave={hide}>
148
- <svg
149
- width="12"
150
- height="12"
151
- viewBox="0 0 24 24"
152
- fill="none"
153
- stroke="currentColor"
154
- strokeWidth="2"
155
- strokeLinecap="round"
156
- strokeLinejoin="round"
157
- className="text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help"
177
+ <button
178
+ type="button"
179
+ aria-label="About video formats"
180
+ aria-expanded={open}
181
+ aria-describedby={open ? panelId : undefined}
182
+ onFocus={show}
183
+ onBlur={hide}
184
+ onClick={() => setOpen((prev) => !prev)}
185
+ className="flex items-center justify-center p-0.5 -m-0.5 rounded text-panel-text-5 hover:text-panel-text-3 transition-colors cursor-help outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
158
186
  >
159
- <circle cx="12" cy="12" r="10" />
160
- <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
161
- <line x1="12" y1="17" x2="12.01" y2="17" />
162
- </svg>
187
+ <svg
188
+ width="12"
189
+ height="12"
190
+ viewBox="0 0 24 24"
191
+ fill="none"
192
+ stroke="currentColor"
193
+ strokeWidth="2"
194
+ strokeLinecap="round"
195
+ strokeLinejoin="round"
196
+ aria-hidden="true"
197
+ >
198
+ <circle cx="12" cy="12" r="10" />
199
+ <path d="M9.09 9a3 3 0 015.83 1c0 2-3 3-3 3" />
200
+ <line x1="12" y1="17" x2="12.01" y2="17" />
201
+ </svg>
202
+ </button>
163
203
  {open && (
164
- <div className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50">
204
+ <div
205
+ id={panelId}
206
+ role="tooltip"
207
+ className="absolute top-full right-0 mt-1.5 w-52 p-2 rounded bg-panel-input border border-neutral-700 shadow-lg z-50"
208
+ >
165
209
  <p className="text-[10px] font-semibold text-panel-text-1 mb-0.5">{info.label}</p>
166
210
  <p className="text-[9px] text-panel-text-3 leading-tight">{info.desc}</p>
167
211
  <div className="mt-1.5 pt-1.5 border-t border-neutral-800">
@@ -191,14 +235,21 @@ const QUALITY_OPTIONS: {
191
235
  { value: "high", label: "High Quality", title: "Best quality, larger file" },
192
236
  ];
193
237
 
238
+ function formatEta(ms: number): string {
239
+ const s = Math.round(ms / 1000);
240
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
241
+ }
242
+
194
243
  function FormatExportButton({
195
244
  onStartRender,
196
245
  isRendering,
197
246
  compositionDimensions,
247
+ lastRenderDurationMs,
198
248
  }: {
199
249
  onStartRender: StartRenderHandler;
200
250
  isRendering: boolean;
201
251
  compositionDimensions?: CompositionDimensions | null;
252
+ lastRenderDurationMs?: number;
202
253
  }) {
203
254
  const persisted = getPersistedRenderSettings();
204
255
  const [format, setFormat] = useState<"mp4" | "webm" | "mov">(persisted.format);
@@ -293,16 +344,26 @@ function FormatExportButton({
293
344
  </div>
294
345
  )}
295
346
  </div>
296
- <button
347
+ <Button
348
+ variant="primary"
349
+ size="md"
350
+ loading={isRendering}
297
351
  onClick={() => {
352
+ // loading already disables the button; this guard also stops a
353
+ // double-click in the same frame from enqueueing two renders.
354
+ if (isRendering) return;
298
355
  trackStudioEvent("render_start", { format, quality, resolution, fps });
299
356
  void onStartRender(format, quality, resolution, fps);
300
357
  }}
301
- disabled={isRendering}
302
- className="w-full flex items-center justify-center h-8 text-[11px] font-semibold rounded-md bg-panel-accent text-[#09090B] hover:brightness-110 transition-colors disabled:opacity-50"
358
+ className="w-full text-[11px] font-semibold"
303
359
  >
304
- {isRendering ? "Rendering..." : "Export"}
305
- </button>
360
+ {isRendering ? "Rendering" : "Export"}
361
+ </Button>
362
+ {lastRenderDurationMs !== undefined && !isRendering && (
363
+ <p className="text-[9px] text-panel-text-5 text-center -mt-1.5">
364
+ Last render took {formatEta(lastRenderDurationMs)}
365
+ </p>
366
+ )}
306
367
  </div>
307
368
  );
308
369
  }
@@ -311,9 +372,14 @@ export const RenderQueue = memo(function RenderQueue({
311
372
  jobs,
312
373
  projectId,
313
374
  onDelete,
375
+ onCancel,
314
376
  onClearCompleted,
315
377
  onStartRender,
316
378
  isRendering,
379
+ loadError,
380
+ onRetryLoad,
381
+ actionError,
382
+ onDismissActionError,
317
383
  compositionDimensions,
318
384
  }: RenderQueueProps) {
319
385
  const listRef = useRef<HTMLDivElement>(null);
@@ -327,6 +393,9 @@ export const RenderQueue = memo(function RenderQueue({
327
393
  }, [jobs.length]);
328
394
 
329
395
  const completedCount = jobs.filter((j) => j.status !== "rendering").length;
396
+ const lastRenderDurationMs = [...jobs]
397
+ .reverse()
398
+ .find((j) => j.status === "complete" && j.durationMs !== undefined)?.durationMs;
330
399
 
331
400
  return (
332
401
  <div className="flex flex-col h-full">
@@ -335,12 +404,40 @@ export const RenderQueue = memo(function RenderQueue({
335
404
  onStartRender={onStartRender}
336
405
  isRendering={isRendering}
337
406
  compositionDimensions={compositionDimensions}
407
+ lastRenderDurationMs={lastRenderDurationMs}
338
408
  />
339
409
  </div>
340
410
 
411
+ {actionError && (
412
+ <div
413
+ role="alert"
414
+ className="flex items-start justify-between gap-2 px-3 py-2 border-b border-panel-border bg-red-500/10"
415
+ >
416
+ <span className="text-[10px] text-red-400">{actionError}</span>
417
+ {onDismissActionError && (
418
+ <button
419
+ onClick={onDismissActionError}
420
+ aria-label="Dismiss error"
421
+ className="text-[10px] text-panel-text-4 hover:text-panel-text-2 flex-shrink-0"
422
+ >
423
+
424
+ </button>
425
+ )}
426
+ </div>
427
+ )}
428
+
341
429
  {/* Job list */}
342
430
  <div ref={listRef} className="flex-1 overflow-y-auto">
343
- {jobs.length === 0 ? (
431
+ {loadError && jobs.length === 0 ? (
432
+ <div className="flex flex-col items-center justify-center h-full px-4 gap-2" role="alert">
433
+ <p className="text-[10px] text-red-400 text-center">{loadError}</p>
434
+ {onRetryLoad && (
435
+ <Button size="sm" variant="secondary" onClick={onRetryLoad}>
436
+ Retry
437
+ </Button>
438
+ )}
439
+ </div>
440
+ ) : jobs.length === 0 ? (
344
441
  <div className="flex flex-col items-center justify-center h-full px-4 gap-2">
345
442
  <svg
346
443
  width="20"
@@ -376,11 +473,14 @@ export const RenderQueue = memo(function RenderQueue({
376
473
  <span className="text-[10px] text-panel-text-4">
377
474
  {jobs.length} render{jobs.length === 1 ? "" : "s"}
378
475
  </span>
476
+ {/* "Hide", not "Clear": files stay on disk (delete is per-row
477
+ and confirmed); hidden rows don't resurrect on reload. */}
379
478
  <button
380
479
  onClick={onClearCompleted}
480
+ title="Hide finished renders from this list (files stay on disk)"
381
481
  className="text-[10px] text-panel-text-4 hover:text-panel-text-2 transition-colors"
382
482
  >
383
- Clear
483
+ Hide finished
384
484
  </button>
385
485
  </div>
386
486
  )}
@@ -390,6 +490,7 @@ export const RenderQueue = memo(function RenderQueue({
390
490
  job={job}
391
491
  projectId={projectId}
392
492
  onDelete={() => onDelete(job.id)}
493
+ onCancel={() => onCancel?.(job.id)}
393
494
  />
394
495
  ))}
395
496
  </div>
@@ -1,11 +1,13 @@
1
1
  import { memo, useCallback, useState } from "react";
2
2
  import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail";
3
+ import { Button } from "../ui/Button";
3
4
  import type { RenderJob } from "./useRenderQueue";
4
5
 
5
6
  interface RenderQueueItemProps {
6
7
  job: RenderJob;
7
8
  projectId: string;
8
9
  onDelete: () => void;
10
+ onCancel: () => void;
9
11
  }
10
12
 
11
13
  function formatDuration(ms: number): string {
@@ -27,8 +29,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({
27
29
  job,
28
30
  projectId,
29
31
  onDelete,
32
+ onCancel,
30
33
  }: RenderQueueItemProps) {
31
34
  const [hovered, setHovered] = useState(false);
35
+ const [videoReady, setVideoReady] = useState(false);
36
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
32
37
 
33
38
  // Direct file URL — serves from disk, survives server restarts
34
39
  const fileSrc = `/api/projects/${projectId}/renders/file/${job.filename}`;
@@ -50,25 +55,35 @@ export const RenderQueueItem = memo(function RenderQueueItem({
50
55
 
51
56
  const viewSrc = fileSrc;
52
57
  const isComplete = job.status === "complete";
58
+ const isRendering = job.status === "rendering";
53
59
 
54
60
  return (
55
61
  <div
56
62
  onPointerEnter={() => setHovered(true)}
57
- onPointerLeave={() => setHovered(false)}
58
- onClick={isComplete ? handleOpen : undefined}
59
- className={[
60
- "px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150",
61
- isComplete ? "cursor-pointer hover:bg-panel-hover/30" : "",
62
- ]
63
- .filter(Boolean)
64
- .join(" ")}
63
+ onPointerLeave={() => {
64
+ setHovered(false);
65
+ setVideoReady(false);
66
+ setConfirmingDelete(false);
67
+ }}
68
+ className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30"
65
69
  >
66
70
  <div className="flex items-center gap-2.5">
67
- {/* Thumbnail — static frame; swaps to live video on hover */}
68
- <div className="w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative">
71
+ {/* Thumbnail — static frame; swaps to live video on hover.
72
+ A real button so keyboard users can open the render too. */}
73
+ <button
74
+ type="button"
75
+ onClick={isComplete ? handleOpen : undefined}
76
+ disabled={!isComplete}
77
+ aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined}
78
+ className={[
79
+ "w-20 h-[45px] rounded-md overflow-hidden bg-panel-input flex-shrink-0 relative",
80
+ "outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent",
81
+ isComplete ? "cursor-pointer" : "cursor-default",
82
+ ].join(" ")}
83
+ >
69
84
  {isComplete && (
70
85
  <>
71
- {/* Live video — visible on hover */}
86
+ {/* Live video — fades in over the static frame once it can play */}
72
87
  {hovered && (
73
88
  <video
74
89
  src={viewSrc}
@@ -76,21 +91,23 @@ export const RenderQueueItem = memo(function RenderQueueItem({
76
91
  muted
77
92
  loop
78
93
  playsInline
79
- className="absolute inset-0 w-full h-full object-contain"
94
+ onCanPlay={() => setVideoReady(true)}
95
+ className="absolute inset-0 w-full h-full object-contain transition-opacity duration-150"
96
+ style={{ opacity: videoReady ? 1 : 0 }}
80
97
  />
81
98
  )}
82
99
  {/* Static frame — visible when not hovering */}
83
100
  <div
84
101
  className="absolute inset-0 transition-opacity duration-150"
85
- style={{ opacity: hovered ? 0 : 1 }}
102
+ style={{ opacity: hovered && videoReady ? 0 : 1 }}
86
103
  >
87
104
  <VideoFrameThumbnail src={viewSrc} />
88
105
  </div>
89
106
  </>
90
107
  )}
91
- {job.status === "rendering" && (
108
+ {isRendering && (
92
109
  <div className="w-full h-full flex items-center justify-center">
93
- <div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse" />
110
+ <div className="w-2 h-2 rounded-full bg-panel-accent animate-pulse motion-reduce:animate-none" />
94
111
  </div>
95
112
  )}
96
113
  {job.status === "failed" && (
@@ -103,7 +120,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({
103
120
  <div className="w-2 h-2 rounded-full bg-neutral-600" />
104
121
  </div>
105
122
  )}
106
- </div>
123
+ </button>
107
124
 
108
125
  {/* Info */}
109
126
  <div className="flex-1 min-w-0">
@@ -118,13 +135,20 @@ export const RenderQueueItem = memo(function RenderQueueItem({
118
135
  )}
119
136
  </div>
120
137
 
121
- {job.status === "rendering" && (
138
+ {isRendering && (
122
139
  <div className="mt-1">
123
140
  <div className="flex items-center justify-between mb-0.5">
124
141
  <span className="text-[9px] text-panel-text-4">{job.stage || "Rendering"}</span>
125
142
  <span className="text-[9px] font-mono text-panel-accent">{job.progress}%</span>
126
143
  </div>
127
- <div className="w-full h-1 bg-panel-border rounded-full overflow-hidden">
144
+ <div
145
+ className="w-full h-1 bg-panel-border rounded-full overflow-hidden"
146
+ role="progressbar"
147
+ aria-valuenow={job.progress}
148
+ aria-valuemin={0}
149
+ aria-valuemax={100}
150
+ aria-label={`Render progress: ${job.progress}%`}
151
+ >
128
152
  <div
129
153
  className="h-full bg-panel-accent rounded-full transition-all duration-300"
130
154
  style={{ width: `${job.progress}%` }}
@@ -136,59 +160,103 @@ export const RenderQueueItem = memo(function RenderQueueItem({
136
160
  {job.status === "failed" && job.error && (
137
161
  <span className="text-[9px] text-red-400 mt-0.5 block">{job.error}</span>
138
162
  )}
163
+ {job.status === "cancelled" && (
164
+ <span className="text-[9px] text-panel-text-4 mt-0.5 block">Cancelled</span>
165
+ )}
139
166
 
140
- {job.status !== "rendering" && (
167
+ {!isRendering && (
141
168
  <span className="text-[9px] text-panel-text-5">{formatTimeAgo(job.createdAt)}</span>
142
169
  )}
143
170
  </div>
144
171
 
145
172
  {/* Actions — always visible to prevent layout shifts */}
146
- <div className="flex items-center gap-1 flex-shrink-0">
147
- <button
148
- onClick={isComplete ? handleDownload : undefined}
149
- className={`p-1 rounded transition-colors ${
150
- isComplete
151
- ? "text-panel-text-5 hover:text-panel-accent"
152
- : "text-panel-text-5/30 pointer-events-none"
153
- }`}
154
- title={isComplete ? "Download" : "Rendering..."}
155
- disabled={!isComplete}
156
- >
157
- <svg
158
- width="12"
159
- height="12"
160
- viewBox="0 0 24 24"
161
- fill="none"
162
- stroke="currentColor"
163
- strokeWidth="2"
164
- strokeLinecap="round"
165
- strokeLinejoin="round"
166
- >
167
- <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
168
- <polyline points="7 10 12 15 17 10" />
169
- <line x1="12" y1="15" x2="12" y2="3" />
170
- </svg>
171
- </button>
172
- <button
173
- onClick={(e) => {
174
- e.stopPropagation();
175
- onDelete();
176
- }}
177
- className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
178
- title="Remove"
179
- >
180
- <svg
181
- width="12"
182
- height="12"
183
- viewBox="0 0 24 24"
184
- fill="none"
185
- stroke="currentColor"
186
- strokeWidth="2"
187
- strokeLinecap="round"
173
+ <div className="flex items-center gap-1.5 flex-shrink-0">
174
+ {isRendering ? (
175
+ <Button
176
+ size="sm"
177
+ variant="secondary"
178
+ onClick={(e) => {
179
+ e.stopPropagation();
180
+ onCancel();
181
+ }}
188
182
  >
189
- <path d="M18 6L6 18M6 6l12 12" />
190
- </svg>
191
- </button>
183
+ Cancel
184
+ </Button>
185
+ ) : confirmingDelete ? (
186
+ <>
187
+ <Button
188
+ size="sm"
189
+ variant="danger"
190
+ onClick={(e) => {
191
+ e.stopPropagation();
192
+ setConfirmingDelete(false);
193
+ onDelete();
194
+ }}
195
+ >
196
+ Delete?
197
+ </Button>
198
+ <Button
199
+ size="sm"
200
+ variant="ghost"
201
+ onClick={(e) => {
202
+ e.stopPropagation();
203
+ setConfirmingDelete(false);
204
+ }}
205
+ >
206
+ Keep
207
+ </Button>
208
+ </>
209
+ ) : (
210
+ <>
211
+ <button
212
+ onClick={isComplete ? handleDownload : undefined}
213
+ className={`p-1.5 min-w-6 min-h-6 rounded transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent ${
214
+ isComplete
215
+ ? "text-panel-text-5 hover:text-panel-accent"
216
+ : "text-panel-text-5/30 cursor-default"
217
+ }`}
218
+ title={isComplete ? "Download" : undefined}
219
+ aria-label={`Download ${job.filename}`}
220
+ disabled={!isComplete}
221
+ >
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
+ >
232
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
233
+ <polyline points="7 10 12 15 17 10" />
234
+ <line x1="12" y1="15" x2="12" y2="3" />
235
+ </svg>
236
+ </button>
237
+ <button
238
+ onClick={(e) => {
239
+ e.stopPropagation();
240
+ setConfirmingDelete(true);
241
+ }}
242
+ className="p-1.5 min-w-6 min-h-6 rounded text-panel-text-5 hover:text-red-400 transition-colors outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-studio-accent"
243
+ title="Delete render file"
244
+ aria-label={`Delete ${job.filename}`}
245
+ >
246
+ <svg
247
+ width="12"
248
+ height="12"
249
+ viewBox="0 0 24 24"
250
+ fill="none"
251
+ stroke="currentColor"
252
+ strokeWidth="2"
253
+ strokeLinecap="round"
254
+ >
255
+ <path d="M18 6L6 18M6 6l12 12" />
256
+ </svg>
257
+ </button>
258
+ </>
259
+ )}
192
260
  </div>
193
261
  </div>
194
262
  </div>