From 4fe33905258516276857a3a4bbea443b98a76ced Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 8 Sep 2026 15:57:17 -0400 Subject: [PATCH] feat(studio): the Renders panel on the shared primitives The four native selects become the shared Select, so no OS popup is left in the panel and the four controls read as the same control as everything around them. Disabled options survive the move: SelectOption gains a disabled flag, which is what keeps a resolution the composition cannot reach out of the keyboard's path while its label still explains why. Export drops its own type size. cn resolved that override by dropping the size recipe's step, which is what left the panel Export a type step below the header's; the computed-style table now reads 28px / 6px / 12px for both. Its two per-row siblings become IconButton with a Tooltip carrying what the title attribute used to say. The panel's colours move to the semantic tokens. --- .../components/renders/RenderQueue.test.tsx | 156 +++++++++++++-- .../src/components/renders/RenderQueue.tsx | 188 +++++++++--------- .../components/renders/RenderQueueItem.tsx | 133 +++++++------ packages/studio/src/components/ui/Select.tsx | 9 + 4 files changed, 310 insertions(+), 176 deletions(-) diff --git a/packages/studio/src/components/renders/RenderQueue.test.tsx b/packages/studio/src/components/renders/RenderQueue.test.tsx index dee603c965..f949a05ee2 100644 --- a/packages/studio/src/components/renders/RenderQueue.test.tsx +++ b/packages/studio/src/components/renders/RenderQueue.test.tsx @@ -4,6 +4,10 @@ import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RenderQueue } from "./RenderQueue"; +import { getPersistedRenderSettings } from "./renderSettings"; +import { buttonBase, buttonSizes, buttonVariants, cn } from "../ui"; +import { isTypingTarget } from "../../utils/typingTarget"; +import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts"; import type { FfmpegStatus } from "./useFfmpegStatus"; // Encoder availability arrives as a prop (useRenderQueue owns the probe), so @@ -18,6 +22,9 @@ let root: Root | null = null; beforeEach(() => { ffmpegStatus = { ok: true }; recheck.mockClear(); + // The format, frame-rate and quality controls write through to the real + // store, so each case has to start from the shipped defaults. + localStorage.clear(); }); afterEach(() => { @@ -26,7 +33,10 @@ afterEach(() => { document.body.innerHTML = ""; }); -function mountRenderQueue(onStartRender: ReturnType) { +function mountRenderQueue( + onStartRender: ReturnType, + compositionDimensions = { width: 1920, height: 1080 }, +) { const host = document.createElement("div"); document.body.append(host); root = createRoot(host); @@ -39,7 +49,7 @@ function mountRenderQueue(onStartRender: ReturnType) { onClearCompleted={vi.fn()} onStartRender={onStartRender} isRendering={false} - compositionDimensions={{ width: 1920, height: 1080 }} + compositionDimensions={compositionDimensions} ffmpeg={ffmpegStatus} ffmpegChecking={false} onRecheckFfmpeg={recheck} @@ -49,37 +59,141 @@ function mountRenderQueue(onStartRender: ReturnType) { return host; } -describe("RenderQueue resolution submission", () => { - it("submits the canonical landscape 4K preset selected by the user", () => { +/** Base UI moves focus a task later than React renders; happy-dom is no faster. */ +const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0)))); + +function fire(el: Element, type: string, key?: string) { + const event = + key === undefined + ? new MouseEvent(type, { bubbles: true }) + : new KeyboardEvent(type, { bubbles: true, key }); + act(() => void el.dispatchEvent(event)); +} + +function triggerFor(host: HTMLElement, label: string): HTMLElement { + const trigger = host.querySelector(`[role="combobox"][aria-label="${label}"]`); + if (!trigger) throw new Error(`no select labelled ${label}`); + return trigger; +} + +/** + * Opens a Select and walks the highlight down `steps` items before committing, + * the way a keyboard user does. The trigger is a real ` {lastRenderDurationMs !== undefined && !isRendering && ( -

+

Last render took {formatEta(lastRenderDurationMs)}

)} @@ -455,7 +450,7 @@ export const RenderQueue = memo(function RenderQueue({ return (
-
+
- {actionError} + {actionError} {onDismissActionError && ( - + className="shrink-0" + icon={ + + } + /> )}
)} @@ -489,7 +497,7 @@ export const RenderQueue = memo(function RenderQueue({
{loadError && jobs.length === 0 ? (
-

{loadError}

+

{loadError}

{onRetryLoad && (
) : (
{completedCount > 0 && ( -
- +
+ {jobs.length} render{jobs.length === 1 ? "" : "s"} {/* "Hide", not "Clear": files stay on disk (delete is per-row and confirmed); hidden rows don't resurrect on reload. */} - + + +
)} {jobs.map((job) => ( diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index bad11f0a5e..9cfa97f94d 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -1,6 +1,8 @@ import { memo, useCallback, useState } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { Button } from "../ui/Button"; +import { IconButton } from "../ui/IconButton"; +import { Tooltip } from "../ui/Tooltip"; import type { RenderJob } from "./useRenderQueue"; interface RenderQueueItemProps { @@ -65,7 +67,7 @@ export const RenderQueueItem = memo(function RenderQueueItem({ setVideoReady(false); setConfirmingDelete(false); }} - className="px-3 py-2.5 border-b border-panel-border last:border-0 transition-colors duration-150 hover:bg-panel-hover/30" + className="px-3 py-2.5 border-b border-border last:border-0 transition-colors duration-150 hover:bg-hover/30" >
{/* Thumbnail — static frame; swaps to live video on hover. @@ -76,8 +78,8 @@ export const RenderQueueItem = memo(function RenderQueueItem({ disabled={!isComplete} aria-label={isComplete ? `Open ${job.filename} in a new tab` : undefined} className={[ - "w-20 h-[45px] rounded-md overflow-hidden bg-panel-input shrink-0 relative", - "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-studio-accent", + "w-20 h-[45px] rounded-md overflow-hidden bg-input shrink-0 relative", + "outline-hidden focus-visible:outline-solid focus-visible:outline-2 focus-visible:outline-accent", isComplete ? "cursor-pointer" : "cursor-default", ].join(" ")} > @@ -107,17 +109,17 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )} {isRendering && (
-
+
)} {job.status === "failed" && (
-
+
)} {job.status === "cancelled" && (
-
+
)} @@ -125,11 +127,9 @@ export const RenderQueueItem = memo(function RenderQueueItem({ {/* Info */}
- - {job.filename} - + {job.filename} {job.durationMs && ( - + {formatDuration(job.durationMs)} )} @@ -138,11 +138,11 @@ export const RenderQueueItem = memo(function RenderQueueItem({ {isRendering && (
- {job.stage || "Rendering"} - {job.progress}% + {job.stage || "Rendering"} + {job.progress}%
@@ -158,14 +158,14 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )} {job.status === "failed" && job.error && ( - {job.error} + {job.error} )} {job.status === "cancelled" && ( - Cancelled + Cancelled )} {!isRendering && ( - {formatTimeAgo(job.createdAt)} + {formatTimeAgo(job.createdAt)} )}
@@ -208,53 +208,58 @@ export const RenderQueueItem = memo(function RenderQueueItem({ ) : ( <> - - + {/* The tooltip explains the control whether or not it is + reachable; `disabled:` on the shared Button keeps pointer + events alive precisely so a disabled one can still say why. */} + + + + { + e.stopPropagation(); + setConfirmingDelete(true); + }} + aria-label={`Delete ${job.filename}`} + icon={ + + } + /> + )}
diff --git a/packages/studio/src/components/ui/Select.tsx b/packages/studio/src/components/ui/Select.tsx index 08158f2531..15c15c18e8 100644 --- a/packages/studio/src/components/ui/Select.tsx +++ b/packages/studio/src/components/ui/Select.tsx @@ -24,6 +24,13 @@ import type { PreviewState } from "./Button"; export interface SelectOption { label: string; value: string; + /** + * Offered but not choosable. The option stays in the list with its label + * intact, because a label is where a caller explains *why* it is out of + * reach; filtering it out would leave the user hunting for a choice that + * silently vanished. + */ + disabled?: boolean; } export interface SelectProps { @@ -94,11 +101,13 @@ export function Select({ {option.label}