@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.
- package/dist/assets/{hyperframes-player-I5YRzB02.js → hyperframes-player-DKWM-Ons.js} +1 -1
- package/dist/assets/index-DrHQAuGO.css +1 -0
- package/dist/assets/index-HfP3sap2.js +390 -0
- package/dist/assets/{index-BTAfgesM.js → index-UvW6BEwf.js} +1 -1
- package/dist/assets/{index-Du5x7KUR.js → index-Y4OnpTEM.js} +1 -1
- package/dist/index.css +29 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +13 -6
- package/dist/index.html +2 -2
- package/dist/index.js +7664 -7953
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -0
- package/package.json +8 -7
- package/src/App.tsx +42 -48
- package/src/components/EditorShell.selectionSync.test.tsx +9 -4
- package/src/components/EditorShell.tsx +52 -56
- package/src/components/StudioHeader.dom.test.tsx +1 -1
- package/src/components/StudioHeader.test.ts +0 -12
- package/src/components/StudioHeader.tsx +10 -13
- package/src/components/StudioLeftPanels.tsx +202 -0
- package/src/components/{StudioRightPanel.tsx → StudioRightPanels.tsx} +57 -206
- package/src/components/{StudioRightPanel.types.ts → StudioRightPanels.types.ts} +4 -3
- package/src/components/dock/Dock.test.tsx +16 -2
- package/src/components/dock/Dock.tsx +13 -3
- package/src/components/dock/dockLayoutStore.test.ts +28 -0
- package/src/components/dock/dockLayoutStore.ts +21 -4
- package/src/components/editor/SnapToolbar.test.tsx +0 -3
- package/src/components/nle/NLEContext.tsx +0 -43
- package/src/components/nle/TimelinePane.tsx +46 -67
- package/src/components/panels/SlideshowPanel.tsx +1 -1
- package/src/components/panels/VariablesPanel.tsx +1 -1
- package/src/components/renders/RenderQueue.test.tsx +133 -25
- package/src/components/renders/RenderQueue.tsx +99 -92
- package/src/components/renders/RenderQueueItem.tsx +69 -64
- package/src/components/renders/RenderQueuePanel.tsx +1 -1
- package/src/components/sidebar/{LeftSidebar.masterComposition.test.tsx → CompositionsPanel.masterComposition.test.tsx} +6 -9
- package/src/components/sidebar/CompositionsPanel.tsx +15 -0
- package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx} +6 -11
- package/src/components/sidebar/SidebarLintButton.tsx +50 -0
- package/src/components/ui/Select.tsx +4 -0
- package/src/contexts/PanelLayoutContext.tsx +3 -57
- package/src/headerExports.test.tsx +23 -0
- package/src/hooks/appHotkeysDispatch.ts +3 -4
- package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +0 -2
- package/src/hooks/useAppHotkeys.test.ts +0 -1
- package/src/hooks/useAppHotkeys.textEditing.test.tsx +0 -3
- package/src/hooks/useAppHotkeys.ts +0 -4
- package/src/hooks/useDomEditPreviewSync.ts +4 -6
- package/src/hooks/useDomEditSession.ts +0 -7
- package/src/hooks/useDomEditWiring.ts +4 -9
- package/src/hooks/usePanelLayout.test.ts +108 -296
- package/src/hooks/usePanelLayout.ts +50 -255
- package/src/hooks/useRightPanelIntents.test.ts +181 -0
- package/src/hooks/useRightPanelIntents.ts +64 -0
- package/src/hooks/useSlideshowTabState.test.ts +9 -31
- package/src/hooks/useSlideshowTabState.ts +6 -19
- package/src/hooks/useStudioContextValue.test.ts +6 -18
- package/src/hooks/useStudioContextValue.ts +5 -26
- package/src/index.ts +2 -1
- package/src/main.tsx +26 -0
- package/src/player/components/Timeline.tsx +1 -1
- package/src/player/lib/timelinePerformanceDiagnostics.test.ts +3 -2
- package/src/player/lib/timelinePerformanceDiagnostics.ts +1 -1
- package/src/utils/studioHelpers.ts +0 -7
- package/src/utils/studioUiPreferences.test.ts +10 -16
- package/src/utils/studioUiPreferences.ts +0 -16
- package/dist/assets/index-CxAu_Okg.css +0 -1
- package/dist/assets/index-DocJlW2v.js +0 -376
- package/src/components/StudioLeftSidebar.tsx +0 -207
- package/src/components/nle/TimelineResizeDivider.tsx +0 -101
- package/src/components/sidebar/LeftSidebar.storage.test.ts +0 -28
- package/src/components/sidebar/LeftSidebar.tsx +0 -348
- package/src/hooks/useInspectorSplitResize.ts +0 -51
- package/src/utils/fitPanels.test.ts +0 -164
- 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-
|
|
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-
|
|
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-
|
|
239
|
-
<p className="text-
|
|
240
|
-
<div className="mt-1.5 pt-1.5 border-t border-
|
|
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-
|
|
245
|
-
<span className="text-
|
|
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
|
|
259
|
-
value: "
|
|
260
|
-
label:
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
{ value: "
|
|
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-
|
|
328
|
+
<span className="text-step-10 text-text-4">Format</span>
|
|
321
329
|
<FormatInfoTooltip format={format} />
|
|
322
330
|
</div>
|
|
323
|
-
<
|
|
331
|
+
<Select
|
|
332
|
+
label="Format"
|
|
324
333
|
value={format}
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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-
|
|
340
|
-
<
|
|
344
|
+
<span className="text-step-10 text-text-4">Resolution</span>
|
|
345
|
+
<Select
|
|
346
|
+
label="Resolution"
|
|
341
347
|
value={resolution}
|
|
342
|
-
|
|
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
|
-
|
|
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-
|
|
359
|
-
<
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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-
|
|
377
|
-
<
|
|
373
|
+
<span className="text-step-10 text-text-4">Quality</span>
|
|
374
|
+
<Select
|
|
375
|
+
label="Quality"
|
|
378
376
|
value={quality}
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
470
|
+
<span className="text-step-10 text-danger">{actionError}</span>
|
|
475
471
|
{onDismissActionError && (
|
|
476
|
-
<
|
|
472
|
+
<IconButton
|
|
473
|
+
size="sm"
|
|
477
474
|
onClick={onDismissActionError}
|
|
478
475
|
aria-label="Dismiss error"
|
|
479
|
-
className="
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
531
|
-
<span className="text-
|
|
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
|
-
<
|
|
537
|
-
onClick={onClearCompleted}
|
|
538
|
-
|
|
539
|
-
|
|
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-
|
|
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-
|
|
81
|
-
"outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
143
|
-
<span className="text-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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 {
|
|
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("
|
|
23
|
+
describe("CompositionsPanel root badge source", () => {
|
|
24
24
|
it("derives the root badge from the filtered compositions list, not the raw file tree", () => {
|
|
25
|
-
//
|
|
26
|
-
// server's filtered `compositions` list
|
|
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
|
-
<
|
|
32
|
+
<CompositionsPanel
|
|
34
33
|
projectId="demo"
|
|
35
34
|
compositions={["hero.html"]}
|
|
36
|
-
assets={[]}
|
|
37
|
-
fileTree={["index.html", "hero.html"]}
|
|
38
35
|
activeComposition={null}
|
|
39
|
-
|
|
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
|
+
}
|
package/src/components/sidebar/{LeftSidebar.lintBadge.test.tsx → SidebarLintButton.test.tsx}
RENAMED
|
@@ -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 {
|
|
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
|
-
<
|
|
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
|
-
|
|
36
|
-
|
|
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("
|
|
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");
|