@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
@@ -25,6 +25,8 @@ export interface DockSnapshot {
25
25
  /** Open panels whose tab is showing and whose group is not hidden. */
26
26
  visiblePanels: ReadonlySet<PanelId>;
27
27
  activePanel: PanelId | null;
28
+ /** The tab each group shows, so a restored layout still records what every side column last showed. */
29
+ groupActivePanels?: readonly PanelId[];
28
30
  }
29
31
 
30
32
  type LastActive = Partial<Record<PanelZone, PanelId>>;
@@ -53,13 +55,17 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
53
55
  lastActive: {},
54
56
  pendingActivation: null,
55
57
  attach: (controller) => set({ controller }),
56
- detach: () => set({ controller: null }),
58
+ detach: () => set({ controller: null, lastActive: {} }),
57
59
  sync: (snapshot) =>
58
60
  set((state) => {
61
+ const lastActive = { ...state.lastActive };
62
+ for (const id of snapshot.groupActivePanels ?? []) {
63
+ const zone = PANEL_DEFINITIONS[id].zone;
64
+ if (zone !== "center") lastActive[zone] = id;
65
+ }
59
66
  const { activePanel } = snapshot;
60
- if (!activePanel) return snapshot;
61
- const zone = PANEL_DEFINITIONS[activePanel].zone;
62
- return { ...snapshot, lastActive: { ...state.lastActive, [zone]: activePanel } };
67
+ if (activePanel) lastActive[PANEL_DEFINITIONS[activePanel].zone] = activePanel;
68
+ return { ...snapshot, lastActive };
63
69
  }),
64
70
  takePendingActivation: () => {
65
71
  const { pendingActivation } = get();
@@ -89,3 +95,14 @@ export const useDockLayoutStore = create<DockLayoutState>((set, get) => ({
89
95
  },
90
96
  resetLayout: () => get().controller?.reset(),
91
97
  }));
98
+
99
+ /** The panel of `zone` the user is looking at: the last one focused if still showing, else any showing. */
100
+ export function visiblePanelInZone(
101
+ zone: PanelZone,
102
+ lastActive: LastActive,
103
+ visiblePanels: ReadonlySet<PanelId>,
104
+ ): PanelId | null {
105
+ const last = lastActive[zone];
106
+ if (last && visiblePanels.has(last)) return last;
107
+ return panelsInZone(zone).find((id) => visiblePanels.has(id)) ?? null;
108
+ }
@@ -5,7 +5,6 @@ import { createRoot } from "react-dom/client";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import { useAppHotkeys } from "../../hooks/useAppHotkeys";
7
7
  import { usePlayerStore } from "../../player/store/playerStore";
8
- import type { LeftSidebarHandle } from "../sidebar/LeftSidebar";
9
8
  import type { DomEditSelection } from "./domEditing";
10
9
  import { SnapToolbar } from "./SnapToolbar";
11
10
  import { usePreviewGuidesStore } from "./previewGuidesStore";
@@ -31,7 +30,6 @@ function renderToolbar(onSnapChange = vi.fn()) {
31
30
  function AppHotkeyHarness() {
32
31
  const domEditSelectionRef = useRef<DomEditSelection | null>(null);
33
32
  const clearDomSelectionRef = useRef<() => void>(() => undefined);
34
- const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
35
33
 
36
34
  useAppHotkeys({
37
35
  handleTimelineElementsDelete: vi.fn(async () => {}),
@@ -50,7 +48,6 @@ function AppHotkeyHarness() {
50
48
  showToast: vi.fn(),
51
49
  syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
52
50
  waitForPendingDomEditSaves: vi.fn(async () => undefined),
53
- leftSidebarRef,
54
51
  handleCopy: vi.fn(() => false),
55
52
  handlePaste: vi.fn(async () => undefined),
56
53
  handleCut: vi.fn(async () => false),
@@ -4,17 +4,11 @@ import { useTimelinePlayer, usePlayerStore } from "../../player";
4
4
  import type { TimelineElement } from "../../player";
5
5
  import type { CompositionLevel } from "./CompositionBreadcrumb";
6
6
  import { useCompositionStack } from "./useCompositionStack";
7
- import { MIN_TIMELINE_H, fitTimelineHeight } from "../../utils/fitPanels";
8
7
  import { setCompositionSourceMap } from "../editor/domEditingDom";
9
8
  import { ensureMotionPathPluginLoaded } from "../../utils/gsapSoftReload";
10
- import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
11
9
  import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
12
10
  import { createStableContext } from "../../utils/hmrStableContext";
13
11
 
14
- // Timeline gets a generous default height so the preview isn't oversized and the
15
- // tracks have room to breathe (CapCut-style). Users can still drag the divider.
16
- const DEFAULT_TIMELINE_H = 360;
17
-
18
12
  export function shouldDisableTimelineWhileCompositionLoading(compositionLoading: boolean): boolean {
19
13
  return compositionLoading;
20
14
  }
@@ -34,10 +28,6 @@ export interface NLEContextValue {
34
28
  handleDrillDown: (element: TimelineElement) => void;
35
29
  compIdToSrc: Map<string, string>;
36
30
  // layout state
37
- timelineH: number;
38
- setTimelineH: React.Dispatch<React.SetStateAction<number>>;
39
- persistTimelineH: (height: number) => void;
40
- containerRef: React.RefObject<HTMLDivElement | null>;
41
31
  // composition loading
42
32
  compositionLoading: boolean;
43
33
  setCompositionLoading: (loading: boolean) => void;
@@ -256,35 +246,6 @@ export function NLEProvider({
256
246
  });
257
247
  }, [compIdToSrc]);
258
248
 
259
- // Resizable timeline height — persisted alongside zoom/pan so the user's
260
- // workspace layout survives reloads.
261
- const [timelineH, setTimelineH] = useState(() => {
262
- const stored = readStudioUiPreferences().timelineHeight;
263
- return stored !== undefined && stored >= MIN_TIMELINE_H ? stored : DEFAULT_TIMELINE_H;
264
- });
265
- const persistTimelineH = useCallback((height: number) => {
266
- writeStudioUiPreferences({ timelineHeight: Math.round(height) });
267
- }, []);
268
- const containerRef = useRef<HTMLDivElement>(null);
269
- // A height persisted on a tall window can exceed this window's container and
270
- // collapse the flex-1 preview to 0px. Observing the container rather than
271
- // clamping once at mount is what makes a window RESIZED after load behave the
272
- // same as one loaded at that size: dragging 760 -> 520 tall used to leave the
273
- // timeline at its stored 429px and the preview at 47px.
274
- useEffect(() => {
275
- const element = containerRef.current;
276
- if (!element || typeof ResizeObserver === "undefined") return;
277
- const reconcile = () => {
278
- const containerH = element.getBoundingClientRect().height;
279
- if (!containerH) return;
280
- setTimelineH((prev) => fitTimelineHeight(containerH, prev));
281
- };
282
- reconcile();
283
- const observer = new ResizeObserver(reconcile);
284
- observer.observe(element);
285
- return () => observer.disconnect();
286
- }, []);
287
-
288
249
  const hasLoadedOnceRef = useRef(false);
289
250
  const [compositionLoading, setCompositionLoadingRaw] = useState(true);
290
251
  const setCompositionLoading = useCallback((loading: boolean) => {
@@ -317,10 +278,6 @@ export function NLEProvider({
317
278
  handleNavigateComposition,
318
279
  handleDrillDown,
319
280
  compIdToSrc,
320
- timelineH,
321
- setTimelineH,
322
- persistTimelineH,
323
- containerRef,
324
281
  compositionLoading,
325
282
  setCompositionLoading,
326
283
  timelineDisabled,
@@ -2,7 +2,6 @@ import { useCallback, type ReactNode } from "react";
2
2
  import { Timeline } from "../../player";
3
3
  import type { TimelineElement } from "../../player";
4
4
  import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
5
- import { TimelineResizeDivider } from "./TimelineResizeDivider";
6
5
  import { useTimelineEditContext } from "../../contexts/TimelineEditContext";
7
6
  import { trackStudioExpandedClipEdit } from "../../telemetry/events";
8
7
  import { useNLEContext } from "./NLEContext";
@@ -132,10 +131,6 @@ export function TimelinePane({
132
131
  handleDrillDown,
133
132
  compositionStack,
134
133
  updateCompositionStack,
135
- timelineH,
136
- setTimelineH,
137
- persistTimelineH,
138
- containerRef,
139
134
  timelineDisabled,
140
135
  timelineSessionEpoch,
141
136
  } = useNLEContext();
@@ -254,72 +249,56 @@ export function TimelinePane({
254
249
  );
255
250
 
256
251
  return (
257
- <>
258
- <TimelineResizeDivider
259
- timelineH={timelineH}
260
- setTimelineH={setTimelineH}
261
- persistTimelineH={persistTimelineH}
262
- containerRef={containerRef}
263
- disabled={timelineDisabled}
264
- />
265
-
266
- {/* Timeline section — inner padding (not margin) keeps the divider's
267
- height math exact while giving the panel a gap from the shell edges. */}
252
+ <div className="relative flex h-full flex-col" aria-disabled={timelineDisabled || undefined}>
268
253
  <div
269
- className="relative flex flex-col shrink-0 px-px pb-px"
270
- style={{ height: timelineH }}
271
- aria-disabled={timelineDisabled || undefined}
254
+ className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
255
+ onDoubleClick={(e) => {
256
+ if ((e.target as HTMLElement).closest("[data-clip]")) return;
257
+ if (timelineDisabled) return;
258
+ if (compositionStack.length > 1) {
259
+ updateCompositionStack((prev) => prev.slice(0, -1));
260
+ }
261
+ }}
272
262
  >
263
+ <div className="shrink-0">{timelineToolbar}</div>
264
+ <Timeline
265
+ sessionEpoch={timelineSessionEpoch}
266
+ onSeek={seek}
267
+ onDrillDown={handleDrillDown}
268
+ renderClipContent={renderClipContent}
269
+ onFileDrop={onFileDrop}
270
+ onDeleteElement={handleDeleteElement}
271
+ onAssetDrop={onAssetDrop}
272
+ onBlockDrop={onBlockDrop}
273
+ onCompositionDrop={onCompositionDrop}
274
+ onMoveElement={handleMoveElement}
275
+ onMoveElements={handleMoveElements}
276
+ onResizeElement={handleResizeElement}
277
+ onResizeElements={handleResizeElements}
278
+ onBlockedEditAttempt={onBlockedEditAttempt}
279
+ onSplitElement={handleSplitElement}
280
+ onSelectElement={onSelectTimelineElement}
281
+ onCopyClip={onCopyClip}
282
+ onPasteClip={onPasteClip}
283
+ onDuplicateClip={onDuplicateClip}
284
+ canPasteClip={canPasteClip}
285
+ />
286
+ </div>
287
+ {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
288
+ {timelineDisabled && (
273
289
  <div
274
- className="flex flex-col flex-1 min-h-0 overflow-hidden rounded-lg border border-neutral-800/50 bg-neutral-950"
275
- onDoubleClick={(e) => {
276
- if ((e.target as HTMLElement).closest("[data-clip]")) return;
277
- if (timelineDisabled) return;
278
- if (compositionStack.length > 1) {
279
- updateCompositionStack((prev) => prev.slice(0, -1));
280
- }
281
- }}
290
+ className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
291
+ data-testid="timeline-loading-disabled-overlay"
292
+ role="status"
293
+ onPointerDown={(event) => event.preventDefault()}
294
+ onDragOver={(event) => event.preventDefault()}
295
+ onDrop={(event) => event.preventDefault()}
282
296
  >
283
- <div className="shrink-0">{timelineToolbar}</div>
284
- <Timeline
285
- sessionEpoch={timelineSessionEpoch}
286
- onSeek={seek}
287
- onDrillDown={handleDrillDown}
288
- renderClipContent={renderClipContent}
289
- onFileDrop={onFileDrop}
290
- onDeleteElement={handleDeleteElement}
291
- onAssetDrop={onAssetDrop}
292
- onBlockDrop={onBlockDrop}
293
- onCompositionDrop={onCompositionDrop}
294
- onMoveElement={handleMoveElement}
295
- onMoveElements={handleMoveElements}
296
- onResizeElement={handleResizeElement}
297
- onResizeElements={handleResizeElements}
298
- onBlockedEditAttempt={onBlockedEditAttempt}
299
- onSplitElement={handleSplitElement}
300
- onSelectElement={onSelectTimelineElement}
301
- onCopyClip={onCopyClip}
302
- onPasteClip={onPasteClip}
303
- onDuplicateClip={onDuplicateClip}
304
- canPasteClip={canPasteClip}
305
- />
297
+ <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
298
+ Loading composition…
299
+ </span>
306
300
  </div>
307
- {timelineFooter && <div className="shrink-0">{timelineFooter}</div>}
308
- {timelineDisabled && (
309
- <div
310
- className="absolute inset-0 z-30 cursor-not-allowed bg-black/18 flex items-center justify-center"
311
- data-testid="timeline-loading-disabled-overlay"
312
- role="status"
313
- onPointerDown={(event) => event.preventDefault()}
314
- onDragOver={(event) => event.preventDefault()}
315
- onDrop={(event) => event.preventDefault()}
316
- >
317
- <span className="rounded-md bg-neutral-900/90 px-2.5 py-1 text-[11px] text-neutral-400">
318
- Loading composition…
319
- </span>
320
- </div>
321
- )}
322
- </div>
323
- </>
301
+ )}
302
+ </div>
324
303
  );
325
304
  }
@@ -156,7 +156,7 @@ export function makeSlideshowNotesController(): NotesController {
156
156
  // ── Component ─────────────────────────────────────────────────────────────
157
157
 
158
158
  export interface SlideshowPanelProps {
159
- /** Scenes from the live clip manifest (passed from StudioRightPanel). */
159
+ /** Scenes from the live clip manifest (passed from StudioRightPanels). */
160
160
  scenes: import("./slideshowPanelHelpers").SceneInfo[];
161
161
  /**
162
162
  * Called with the updated manifest after every discrete edit (toggle, add,
@@ -246,7 +246,7 @@ const EMPTY_STATE = (
246
246
  </p>
247
247
  );
248
248
 
249
- // Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanel.
249
+ // Panel orchestrator — JSX conditionals per section, same shape as StudioRightPanels.
250
250
  // fallow-ignore-next-line complexity
251
251
  export const VariablesPanel = memo(function VariablesPanel({
252
252
  sdkSession,
@@ -3,7 +3,11 @@
3
3
  import { act } from "react";
4
4
  import { createRoot, type Root } from "react-dom/client";
5
5
  import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
6
- import { RenderQueue } from "./RenderQueue";
6
+ import { RenderQueue, type StartRenderHandler } from "./RenderQueue";
7
+ import { getPersistedRenderSettings } from "./renderSettings";
8
+ import { buttonBase, buttonSizes, buttonVariants, cn } from "../ui";
9
+ import { isTypingTarget } from "../../utils/typingTarget";
10
+ import { shouldIgnorePlaybackShortcutTarget } from "../../player/lib/playbackShortcuts";
7
11
  import type { FfmpegStatus } from "./useFfmpegStatus";
8
12
 
9
13
  // Encoder availability arrives as a prop (useRenderQueue owns the probe), so
@@ -18,6 +22,9 @@ let root: Root | null = null;
18
22
  beforeEach(() => {
19
23
  ffmpegStatus = { ok: true };
20
24
  recheck.mockClear();
25
+ // The format, frame-rate and quality controls write through to the real
26
+ // store, so each case has to start from the shipped defaults.
27
+ localStorage.clear();
21
28
  });
22
29
 
23
30
  afterEach(() => {
@@ -26,7 +33,10 @@ afterEach(() => {
26
33
  document.body.innerHTML = "";
27
34
  });
28
35
 
29
- function mountRenderQueue(onStartRender: Mock) {
36
+ function mountRenderQueue(
37
+ onStartRender: Mock<StartRenderHandler>,
38
+ compositionDimensions = { width: 1920, height: 1080 },
39
+ ) {
30
40
  const host = document.createElement("div");
31
41
  document.body.append(host);
32
42
  root = createRoot(host);
@@ -39,7 +49,7 @@ function mountRenderQueue(onStartRender: Mock) {
39
49
  onClearCompleted={vi.fn()}
40
50
  onStartRender={onStartRender}
41
51
  isRendering={false}
42
- compositionDimensions={{ width: 1920, height: 1080 }}
52
+ compositionDimensions={compositionDimensions}
43
53
  ffmpeg={ffmpegStatus}
44
54
  ffmpegChecking={false}
45
55
  onRecheckFfmpeg={recheck}
@@ -49,37 +59,135 @@ function mountRenderQueue(onStartRender: Mock) {
49
59
  return host;
50
60
  }
51
61
 
52
- describe("RenderQueue resolution submission", () => {
53
- it("submits the canonical landscape 4K preset selected by the user", () => {
54
- const onStartRender = vi.fn();
55
- const host = mountRenderQueue(onStartRender);
56
- const resolutionSelect = [...host.querySelectorAll("select")].find((select) =>
57
- [...select.options].some((option) => option.textContent?.startsWith("4K")),
62
+ /** Base UI moves focus a task later than React renders; happy-dom is no faster. */
63
+ const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
64
+
65
+ function fire(el: Element, type: string, key?: string) {
66
+ const event =
67
+ key === undefined
68
+ ? new MouseEvent(type, { bubbles: true })
69
+ : new KeyboardEvent(type, { bubbles: true, key });
70
+ act(() => void el.dispatchEvent(event));
71
+ }
72
+
73
+ function triggerFor(host: HTMLElement, label: string): HTMLElement {
74
+ const trigger = host.querySelector<HTMLElement>(`[role="combobox"][aria-label="${label}"]`);
75
+ if (!trigger) throw new Error(`no select labelled ${label}`);
76
+ return trigger;
77
+ }
78
+
79
+ /** Opens a Select and arrows down `steps` items before committing, the way a keyboard user
80
+ * does. happy-dom does not synthesise the click Space would trigger, so it is dispatched here. */
81
+ async function chooseByArrowing(trigger: HTMLElement, steps: number) {
82
+ fire(trigger, "keydown", " ");
83
+ fire(trigger, "keyup", " ");
84
+ act(() => trigger.click());
85
+ await settle();
86
+ for (let i = 0; i < steps; i += 1) {
87
+ fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
88
+ await settle();
89
+ }
90
+ fire(document.activeElement ?? document.body, "keydown", "Enter");
91
+ await settle();
92
+ }
93
+
94
+ function exportButtonIn(host: HTMLElement): HTMLButtonElement {
95
+ const button = host.querySelector<HTMLButtonElement>('[data-testid="renders-export"]');
96
+ if (!button) throw new Error("export button did not render");
97
+ return button;
98
+ }
99
+
100
+ describe("RenderQueue controls", () => {
101
+ it("has no native select left in the panel", () => {
102
+ // R8. The four format / resolution / frame-rate / quality controls are the
103
+ // shared Select now; a native one would bring back an OS popup that no
104
+ // token can reach.
105
+ expect(mountRenderQueue(vi.fn()).querySelector("select")).toBeNull();
106
+ });
107
+
108
+ it("wears exactly the header Export's recipe, plus the full width", () => {
109
+ // AE3. Set equality, not "contains": the bug this replaces was an extra
110
+ // `text-[11px]` on this button, which `cn` resolved by dropping the size
111
+ // recipe's `text-step-12` and left the two Exports a type step apart.
112
+ const shared = cn(buttonBase, buttonVariants.primary, buttonSizes.md);
113
+ const classes = new Set(exportButtonIn(mountRenderQueue(vi.fn())).className.split(/\s+/));
114
+
115
+ expect(classes).toEqual(new Set([...shared.split(/\s+/), "w-full"]));
116
+ });
117
+
118
+ it("classifies the format Select the way it classified the native one (KTD13)", async () => {
119
+ const host = mountRenderQueue(vi.fn());
120
+ const reference = document.createElement("select");
121
+ document.body.append(reference);
122
+ await settle();
123
+
124
+ const trigger = triggerFor(host, "Format");
125
+
126
+ // Both true, not merely equal: two falses would agree and prove nothing.
127
+ expect(isTypingTarget(reference)).toBe(true);
128
+ expect(shouldIgnorePlaybackShortcutTarget(reference)).toBe(true);
129
+ expect(isTypingTarget(trigger)).toBe(isTypingTarget(reference));
130
+ expect(shouldIgnorePlaybackShortcutTarget(trigger)).toBe(
131
+ shouldIgnorePlaybackShortcutTarget(reference),
58
132
  );
59
- if (!resolutionSelect) throw new Error("resolution selector did not render");
133
+ });
134
+
135
+ it("submits the canonical landscape 4K preset selected by the user", async () => {
136
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
137
+ const host = mountRenderQueue(onStartRender);
60
138
 
139
+ // Auto, 1080p, 4K: two steps down from the default.
140
+ await chooseByArrowing(triggerFor(host, "Resolution"), 2);
61
141
  act(() => {
62
- resolutionSelect.value = "4k";
63
- resolutionSelect.dispatchEvent(new Event("change", { bubbles: true }));
142
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
64
143
  });
65
144
 
66
- const exportButton = [...host.querySelectorAll("button")].find(
67
- (button) => button.textContent === "Export",
145
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
146
+ });
147
+
148
+ it("refuses a resolution the composition cannot reach, and says why", async () => {
149
+ // 1080p on a 1280x720 comp is a 1.5x scale, which the producer rejects; the
150
+ // option stays listed (its label explains why) but the keyboard skips it.
151
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
152
+ const host = mountRenderQueue(onStartRender, { width: 1280, height: 720 });
153
+ const trigger = triggerFor(host, "Resolution");
154
+
155
+ fire(trigger, "keydown", " ");
156
+ fire(trigger, "keyup", " ");
157
+ act(() => trigger.click());
158
+ await settle();
159
+ const blocked = [...document.querySelectorAll('[role="option"]')].filter((option) =>
160
+ option.hasAttribute("data-disabled"),
68
161
  );
69
- if (!exportButton) throw new Error("export button did not render");
162
+ expect(blocked).toHaveLength(1);
163
+ expect(blocked[0]?.textContent).toContain("not an integer scale of 1280×720");
164
+
165
+ fire(document.activeElement ?? document.body, "keydown", "ArrowDown");
166
+ await settle();
167
+ fire(document.activeElement ?? document.body, "keydown", "Enter");
168
+ await settle();
169
+ fire(document.activeElement ?? document.body, "keydown", "Escape");
170
+ await settle();
70
171
  act(() => {
71
- exportButton.dispatchEvent(new MouseEvent("click", { bubbles: true }));
172
+ exportButtonIn(host).dispatchEvent(new MouseEvent("click", { bubbles: true }));
72
173
  });
73
174
 
74
- expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "landscape-4k", 30);
175
+ expect(onStartRender).toHaveBeenCalledWith("mp4", "standard", "auto", 30);
75
176
  });
76
- });
77
177
 
78
- function exportButtonIn(host: HTMLElement): HTMLButtonElement {
79
- const button = [...host.querySelectorAll("button")].find((b) => b.textContent === "Export");
80
- if (!button) throw new Error("export button did not render");
81
- return button;
82
- }
178
+ it("persists a changed format as the literal union value", async () => {
179
+ const host = mountRenderQueue(vi.fn());
180
+
181
+ // MP4, MOV, WebM: one step down commits "mov", not the label "MOV (ProRes)".
182
+ await chooseByArrowing(triggerFor(host, "Format"), 1);
183
+
184
+ expect(getPersistedRenderSettings()).toEqual({
185
+ format: "mov",
186
+ quality: "standard",
187
+ fps: 30,
188
+ });
189
+ });
190
+ });
83
191
 
84
192
  describe("RenderQueue Export button", () => {
85
193
  it("leaves the font size to the shared Button", () => {
@@ -97,7 +205,7 @@ describe("RenderQueue FFmpeg gate", () => {
97
205
  hint: "brew install ffmpeg",
98
206
  command: "brew install ffmpeg",
99
207
  };
100
- const onStartRender = vi.fn();
208
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
101
209
  const host = mountRenderQueue(onStartRender);
102
210
 
103
211
  expect(host.textContent).toContain("FFmpeg not found");
@@ -130,7 +238,7 @@ describe("RenderQueue FFmpeg gate", () => {
130
238
  // as "not installed" would lock Export for setups that render fine.
131
239
  it("leaves Export usable when the probe returns no answer", () => {
132
240
  ffmpegStatus = null;
133
- const onStartRender = vi.fn();
241
+ const onStartRender: Mock<StartRenderHandler> = vi.fn();
134
242
  const host = mountRenderQueue(onStartRender);
135
243
 
136
244
  expect(host.textContent).not.toContain("FFmpeg not found");