@hyperframes/studio 0.5.0-alpha.8 → 0.5.0-alpha.9

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/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
6
6
  <title>HyperFrames Studio</title>
7
- <script type="module" crossorigin src="/assets/index-C9f5eif8.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-0Zt0t13W.css">
7
+ <script type="module" crossorigin src="/assets/index-peNJzL-4.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-DKaNgV2Z.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/studio",
3
- "version": "0.5.0-alpha.8",
3
+ "version": "0.5.0-alpha.9",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,8 +32,8 @@
32
32
  "@phosphor-icons/react": "^2.1.10",
33
33
  "codemirror": "^6.0.1",
34
34
  "motion": "^12.38.0",
35
- "@hyperframes/core": "0.5.0-alpha.8",
36
- "@hyperframes/player": "0.5.0-alpha.8"
35
+ "@hyperframes/core": "0.5.0-alpha.9",
36
+ "@hyperframes/player": "0.5.0-alpha.9"
37
37
  },
38
38
  "devDependencies": {
39
39
  "@types/react": "^19.0.0",
@@ -47,7 +47,7 @@
47
47
  "vite": "^6.4.2",
48
48
  "vitest": "^3.2.4",
49
49
  "zustand": "^5.0.0",
50
- "@hyperframes/producer": "0.5.0-alpha.8"
50
+ "@hyperframes/producer": "0.5.0-alpha.9"
51
51
  },
52
52
  "peerDependencies": {
53
53
  "react": "^18.0.0 || ^19.0.0",
package/src/App.tsx CHANGED
@@ -2797,6 +2797,7 @@ export function StudioApp() {
2797
2797
  selection={
2798
2798
  !rightCollapsed && rightPanelTab === "design" ? domEditSelection : null
2799
2799
  }
2800
+ allowCanvasMovement={false}
2800
2801
  onCanvasMouseDown={handlePreviewCanvasMouseDown}
2801
2802
  onCanvasDoubleClick={handlePreviewCanvasDoubleClick}
2802
2803
  onSelectedDoubleClick={handleSelectedOverlayDoubleClick}
@@ -2891,6 +2892,7 @@ export function StudioApp() {
2891
2892
  onImportAssets={handleImportFiles}
2892
2893
  fontAssets={fontAssets}
2893
2894
  onImportFonts={handleImportFonts}
2895
+ allowLayoutDetach={false}
2894
2896
  />
2895
2897
  ) : (
2896
2898
  <RenderQueue
@@ -1,6 +1,7 @@
1
1
  import { memo, useState, useCallback, useRef } from "react";
2
2
  import { useCaptionStore } from "../store";
3
3
  import { useMountEffect } from "../../hooks/useMountEffect";
4
+ import { shouldHandleCaptionNudgeKey } from "../keyboard";
4
5
 
5
6
  interface CaptionOverlayProps {
6
7
  iframeRef: React.RefObject<HTMLIFrameElement | null>;
@@ -329,7 +330,7 @@ export const CaptionOverlay = memo(function CaptionOverlay({ iframeRef }: Captio
329
330
  const { selectedSegmentIds: sel, model: m } = useCaptionStore.getState();
330
331
  if (sel.size === 0 || !m) return;
331
332
  const arrow = e.key;
332
- if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(arrow)) return;
333
+ if (!shouldHandleCaptionNudgeKey(e)) return;
333
334
 
334
335
  e.preventDefault();
335
336
  const step = e.shiftKey ? 10 : 1;
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { shouldHandleCaptionNudgeKey } from "./keyboard";
3
+
4
+ function mockKeyboardEvent(
5
+ key: string,
6
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey">> = {},
7
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key"> {
8
+ return {
9
+ altKey: false,
10
+ ctrlKey: false,
11
+ metaKey: false,
12
+ key,
13
+ ...overrides,
14
+ };
15
+ }
16
+
17
+ describe("shouldHandleCaptionNudgeKey", () => {
18
+ it("handles plain and Shift-modified arrow keys for caption nudging", () => {
19
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft"))).toBe(true);
20
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight"))).toBe(true);
21
+ });
22
+
23
+ it("ignores browser and app shortcut chords", () => {
24
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowLeft", { altKey: true }))).toBe(
25
+ false,
26
+ );
27
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { ctrlKey: true }))).toBe(
28
+ false,
29
+ );
30
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("ArrowRight", { metaKey: true }))).toBe(
31
+ false,
32
+ );
33
+ });
34
+
35
+ it("ignores non-arrow keys", () => {
36
+ expect(shouldHandleCaptionNudgeKey(mockKeyboardEvent("KeyL"))).toBe(false);
37
+ });
38
+ });
@@ -0,0 +1,8 @@
1
+ const CAPTION_NUDGE_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);
2
+
3
+ type CaptionNudgeKeyEvent = Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "key">;
4
+
5
+ export function shouldHandleCaptionNudgeKey(event: CaptionNudgeKeyEvent): boolean {
6
+ if (event.metaKey || event.ctrlKey || event.altKey) return false;
7
+ return CAPTION_NUDGE_KEYS.has(event.key);
8
+ }
@@ -14,6 +14,7 @@ interface OverlayRect {
14
14
  interface DomEditOverlayProps {
15
15
  iframeRef: RefObject<HTMLIFrameElement | null>;
16
16
  selection: DomEditSelection | null;
17
+ allowCanvasMovement?: boolean;
17
18
  onCanvasMouseDown: (
18
19
  event: React.MouseEvent<HTMLDivElement>,
19
20
  options?: { preferClipAncestor?: boolean },
@@ -125,6 +126,7 @@ interface BlockedMoveState {
125
126
  export const DomEditOverlay = memo(function DomEditOverlay({
126
127
  iframeRef,
127
128
  selection,
129
+ allowCanvasMovement = true,
128
130
  onCanvasMouseDown,
129
131
  onCanvasDoubleClick,
130
132
  onSelectedDoubleClick,
@@ -403,9 +405,10 @@ export const DomEditOverlay = memo(function DomEditOverlay({
403
405
  top: overlayRect.top,
404
406
  width: overlayRect.width,
405
407
  height: overlayRect.height,
406
- cursor: selection.capabilities.canMove ? "move" : "default",
408
+ cursor: allowCanvasMovement && selection.capabilities.canMove ? "move" : "default",
407
409
  }}
408
410
  onPointerDown={(e) => {
411
+ if (!allowCanvasMovement) return;
409
412
  if (selection.capabilities.canMove) {
410
413
  startGesture("drag", e);
411
414
  return;
@@ -424,7 +427,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({
424
427
  onDoubleClick={onSelectedDoubleClick}
425
428
  >
426
429
  {/* Resize handle — bottom-right corner */}
427
- {selection.capabilities.canResize && (
430
+ {allowCanvasMovement && selection.capabilities.canResize && (
428
431
  <div
429
432
  className="absolute -right-1.5 -bottom-1.5 w-3 h-3 rounded-sm bg-studio-accent border border-studio-accent/60"
430
433
  style={{ cursor: "se-resize", touchAction: "none" }}
@@ -64,6 +64,7 @@ interface PropertyPanelProps {
64
64
  onImportAssets?: (files: FileList) => Promise<string[]>;
65
65
  fontAssets?: ImportedFontAsset[];
66
66
  onImportFonts?: (files: FileList | File[]) => Promise<ImportedFontAsset[]>;
67
+ allowLayoutDetach?: boolean;
67
68
  }
68
69
 
69
70
  const FIELD =
@@ -1984,6 +1985,7 @@ export const PropertyPanel = memo(function PropertyPanel({
1984
1985
  onImportAssets,
1985
1986
  fontAssets = [],
1986
1987
  onImportFonts,
1988
+ allowLayoutDetach = true,
1987
1989
  }: PropertyPanelProps) {
1988
1990
  const styles = element?.computedStyles ?? EMPTY_STYLES;
1989
1991
  const selectionColors = useMemo(() => collectSelectionColors(styles), [styles]);
@@ -2020,7 +2022,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2020
2022
  <p className="text-sm font-medium text-neutral-200">Select an element in the preview.</p>
2021
2023
  <p className="mt-2 max-w-[260px] text-xs leading-5 text-neutral-500">
2022
2024
  The inspector is tuned for direct DOM edits with safer geometry controls, color picking,
2023
- and cleaner Paper-style grouping.
2025
+ and cleaner grouped layer controls.
2024
2026
  </p>
2025
2027
  </div>
2026
2028
  );
@@ -2036,7 +2038,9 @@ export const PropertyPanel = memo(function PropertyPanel({
2036
2038
  const sourceLabel = element.id ? `#${element.id}` : element.selector;
2037
2039
  const showEditableSections = element.capabilities.canEditStyles;
2038
2040
  const disabledMoveReason =
2039
- element.capabilities.reasonIfDisabled && !element.capabilities.canDetachFromLayout
2041
+ allowLayoutDetach &&
2042
+ element.capabilities.reasonIfDisabled &&
2043
+ !element.capabilities.canDetachFromLayout
2040
2044
  ? element.capabilities.reasonIfDisabled
2041
2045
  : null;
2042
2046
 
@@ -2131,7 +2135,7 @@ export const PropertyPanel = memo(function PropertyPanel({
2131
2135
  </button>
2132
2136
  </div>
2133
2137
  )}
2134
- {element.capabilities.canDetachFromLayout && (
2138
+ {allowLayoutDetach && element.capabilities.canDetachFromLayout && (
2135
2139
  <div className="mt-4 flex min-w-0 flex-wrap items-center justify-between gap-3 border-l border-amber-500/40 pl-3">
2136
2140
  <div className="min-w-0 text-[11px] leading-5 text-neutral-400">
2137
2141
  <div className="font-medium text-neutral-200">
@@ -67,7 +67,11 @@ export const NLEPreview = memo(function NLEPreview({
67
67
 
68
68
  return (
69
69
  <div className="flex flex-col h-full min-h-0">
70
- <div className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0">
70
+ <div
71
+ className="relative flex-1 flex items-center justify-center p-2 overflow-hidden min-h-0 outline-none focus:ring-1 focus:ring-studio-accent/40"
72
+ tabIndex={0}
73
+ aria-label="Composition preview"
74
+ >
71
75
  {retiringKey && (
72
76
  <Player
73
77
  key={retiringKey}
@@ -4,11 +4,18 @@ import {
4
4
  TIMELINE_TOGGLE_SHORTCUT_LABEL,
5
5
  getTimelineToggleTitle,
6
6
  } from "../../utils/timelineDiscovery";
7
- import { formatTime } from "../lib/time";
7
+ import { formatFrameTime, frameToSeconds, formatTime } from "../lib/time";
8
8
  import { usePlayerStore, liveTime } from "../store/playerStore";
9
9
 
10
10
  const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
11
11
  const SEEK_EDGE_SNAP_PX = 8;
12
+ type TimeDisplayMode = "time" | "frame";
13
+ const SHORTCUT_HINTS = [
14
+ { key: "J", label: "Play backward" },
15
+ { key: "K", label: "Stop playback" },
16
+ { key: "L", label: "Play forward" },
17
+ { key: "←/→", label: "Step one frame backward or forward" },
18
+ ] as const;
12
19
 
13
20
  export function resolveSeekPercent(clientX: number, rectLeft: number, rectWidth: number): number {
14
21
  if (!Number.isFinite(rectWidth) || rectWidth <= 0) return 0;
@@ -38,8 +45,12 @@ export const PlayerControls = memo(function PlayerControls({
38
45
  const duration = usePlayerStore((s) => s.duration);
39
46
  const timelineReady = usePlayerStore((s) => s.timelineReady);
40
47
  const playbackRate = usePlayerStore((s) => s.playbackRate);
48
+ const loopEnabled = usePlayerStore((s) => s.loopEnabled);
41
49
  const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
50
+ const setLoopEnabled = usePlayerStore.getState().setLoopEnabled;
42
51
  const [showSpeedMenu, setShowSpeedMenu] = useState(false);
52
+ const [timeDisplayMode, setTimeDisplayMode] = useState<TimeDisplayMode>("time");
53
+ const [jumpFrame, setJumpFrame] = useState("");
43
54
 
44
55
  const progressFillRef = useRef<HTMLDivElement>(null);
45
56
  const progressThumbRef = useRef<HTMLDivElement>(null);
@@ -49,6 +60,8 @@ export const PlayerControls = memo(function PlayerControls({
49
60
  const speedMenuContainerRef = useRef<HTMLDivElement>(null);
50
61
  const isDraggingRef = useRef(false);
51
62
  const currentTimeRef = useRef(0);
63
+ const timeDisplayModeRef = useRef(timeDisplayMode);
64
+ timeDisplayModeRef.current = timeDisplayMode;
52
65
 
53
66
  const durationRef = useRef(duration);
54
67
  durationRef.current = duration;
@@ -59,7 +72,10 @@ export const PlayerControls = memo(function PlayerControls({
59
72
  const pct = dur > 0 ? Math.min(100, (t / dur) * 100) : 0;
60
73
  if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
61
74
  if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
62
- if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
75
+ if (timeDisplayRef.current) {
76
+ timeDisplayRef.current.textContent =
77
+ timeDisplayModeRef.current === "frame" ? formatFrameTime(t, dur) : formatTime(t);
78
+ }
63
79
  if (sliderRef.current) sliderRef.current.setAttribute("aria-valuenow", String(Math.round(t)));
64
80
  };
65
81
  const unsub = liveTime.subscribe(updateProgress);
@@ -82,6 +98,13 @@ export const PlayerControls = memo(function PlayerControls({
82
98
  };
83
99
  });
84
100
 
101
+ useEffect(() => {
102
+ if (!timeDisplayRef.current) return;
103
+ const t = currentTimeRef.current;
104
+ timeDisplayRef.current.textContent =
105
+ timeDisplayMode === "frame" ? formatFrameTime(t, duration) : formatTime(t);
106
+ }, [duration, timeDisplayMode]);
107
+
85
108
  useEffect(() => {
86
109
  if (!showSpeedMenu) return;
87
110
  const handleMouseDown = (e: MouseEvent) => {
@@ -190,21 +213,44 @@ export const PlayerControls = memo(function PlayerControls({
190
213
  const handleKeyDown = useCallback(
191
214
  (e: React.KeyboardEvent) => {
192
215
  if (!timelineReady || duration <= 0) return;
193
- const step = e.shiftKey ? 5 : 1;
216
+ const step = e.shiftKey ? 10 : 1;
194
217
  if (e.key === "ArrowLeft") {
195
218
  e.preventDefault();
196
- onSeek(Math.max(0, currentTimeRef.current - step));
219
+ onSeek(Math.max(0, currentTimeRef.current - frameToSeconds(step)));
197
220
  } else if (e.key === "ArrowRight") {
198
221
  e.preventDefault();
199
- onSeek(Math.min(duration, currentTimeRef.current + step));
222
+ onSeek(Math.min(duration, currentTimeRef.current + frameToSeconds(step)));
200
223
  }
201
224
  },
202
225
  [timelineReady, duration, onSeek],
203
226
  );
204
227
 
228
+ const commitJumpFrame = useCallback(() => {
229
+ const frame = Number.parseInt(jumpFrame, 10);
230
+ if (!Number.isFinite(frame) || duration <= 0) return;
231
+ onSeek(Math.min(duration, frameToSeconds(Math.max(0, frame))));
232
+ }, [duration, jumpFrame, onSeek]);
233
+
234
+ const handleJumpSubmit = useCallback(
235
+ (e: React.FormEvent) => {
236
+ e.preventDefault();
237
+ commitJumpFrame();
238
+ },
239
+ [commitJumpFrame],
240
+ );
241
+
242
+ const handleJumpKeyDown = useCallback(
243
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
244
+ if (e.key !== "Enter") return;
245
+ e.preventDefault();
246
+ commitJumpFrame();
247
+ },
248
+ [commitJumpFrame],
249
+ );
250
+
205
251
  return (
206
252
  <div
207
- className="px-4 py-2 flex items-center gap-3"
253
+ className="px-4 py-2 flex flex-wrap items-center gap-x-2 gap-y-1"
208
254
  style={{
209
255
  borderTop: "1px solid rgba(255,255,255,0.04)",
210
256
  // Add iOS safe-area inset so Safari's bottom URL bar doesn't occlude
@@ -236,12 +282,16 @@ export const PlayerControls = memo(function PlayerControls({
236
282
 
237
283
  {/* Time display */}
238
284
  <span
239
- className="font-mono text-[11px] tabular-nums flex-shrink-0 min-w-[72px]"
285
+ className="font-mono text-[11px] tabular-nums flex-shrink-0 w-[118px]"
240
286
  style={{ color: "#A1A1AA" }}
241
287
  >
242
288
  <span ref={timeDisplayRef}>{formatTime(0)}</span>
243
- <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
244
- <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
289
+ {timeDisplayMode === "time" ? (
290
+ <>
291
+ <span style={{ color: "#3F3F46", margin: "0 2px" }}>/</span>
292
+ <span style={{ color: "#52525B" }}>{formatTime(duration)}</span>
293
+ </>
294
+ ) : null}
245
295
  </span>
246
296
 
247
297
  {/* Seek bar — teal progress fill */}
@@ -256,7 +306,7 @@ export const PlayerControls = memo(function PlayerControls({
256
306
  aria-valuemin={0}
257
307
  aria-valuemax={Math.round(duration)}
258
308
  aria-valuenow={0}
259
- className="flex-1 h-6 flex items-center cursor-pointer group"
309
+ className="min-w-[96px] flex-1 h-6 flex items-center cursor-pointer group"
260
310
  // `touch-action: none` tells the browser we're handling every
261
311
  // pointer gesture on this element ourselves. Without it, iOS
262
312
  // Safari consumes horizontal swipes for its own swipe-back-to-
@@ -292,7 +342,7 @@ export const PlayerControls = memo(function PlayerControls({
292
342
  <button
293
343
  type="button"
294
344
  onClick={() => setShowSpeedMenu((v) => !v)}
295
- className="px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
345
+ className="w-10 px-2 py-1 rounded-md text-[10px] font-mono tabular-nums transition-colors"
296
346
  style={{ color: "#71717A", background: "rgba(255,255,255,0.04)" }}
297
347
  >
298
348
  {playbackRate === 1 ? "1x" : `${playbackRate}x`}
@@ -329,6 +379,65 @@ export const PlayerControls = memo(function PlayerControls({
329
379
  )}
330
380
  </div>
331
381
 
382
+ <button
383
+ type="button"
384
+ onClick={() => setLoopEnabled(!loopEnabled)}
385
+ className={`h-7 w-14 rounded-md border px-2 text-[10px] font-medium transition-colors ${
386
+ loopEnabled
387
+ ? "text-studio-accent bg-studio-accent/10 border-studio-accent/30"
388
+ : "border-neutral-700 text-neutral-400 hover:border-neutral-500 hover:bg-neutral-800"
389
+ }`}
390
+ title="Loop playback"
391
+ aria-label={loopEnabled ? "Disable loop playback" : "Enable loop playback"}
392
+ aria-pressed={loopEnabled}
393
+ >
394
+ Loop
395
+ </button>
396
+
397
+ <button
398
+ type="button"
399
+ onClick={() => setTimeDisplayMode((mode) => (mode === "time" ? "frame" : "time"))}
400
+ className="h-7 w-14 rounded-md border border-neutral-700 px-2 text-[10px] font-mono text-neutral-300 transition-colors hover:border-neutral-500 hover:bg-neutral-800"
401
+ title="Toggle time/frame display"
402
+ aria-label="Toggle time and frame display"
403
+ >
404
+ {timeDisplayMode === "time" ? "m:ss" : "frames"}
405
+ </button>
406
+
407
+ <form
408
+ onSubmit={handleJumpSubmit}
409
+ className="hidden sm:flex flex-shrink-0 w-[58px] items-center"
410
+ >
411
+ <input
412
+ value={jumpFrame}
413
+ onChange={(e) => setJumpFrame(e.target.value)}
414
+ inputMode="numeric"
415
+ pattern="[0-9]*"
416
+ aria-label="Jump to frame"
417
+ placeholder="frame"
418
+ className="h-7 w-[58px] rounded-md border border-neutral-700 bg-neutral-900 px-2 text-[10px] font-mono tabular-nums text-neutral-200 outline-none transition-colors placeholder:text-neutral-600 focus:border-studio-accent/60"
419
+ onKeyDown={handleJumpKeyDown}
420
+ onBlur={commitJumpFrame}
421
+ />
422
+ </form>
423
+
424
+ <div
425
+ className="hidden lg:flex items-center gap-1 text-[9px] font-mono text-neutral-500"
426
+ aria-label="Playback shortcuts: J backward, K stop, L forward, arrows step one frame"
427
+ >
428
+ {SHORTCUT_HINTS.map((shortcut) => (
429
+ <span
430
+ key={shortcut.key}
431
+ className="group relative rounded border border-neutral-800 px-1 py-0.5"
432
+ >
433
+ {shortcut.key}
434
+ <span className="pointer-events-none absolute bottom-full left-1/2 z-50 mb-1.5 hidden -translate-x-1/2 whitespace-nowrap rounded-md border border-neutral-700 bg-neutral-950 px-2 py-1 font-sans text-[10px] text-neutral-200 shadow-lg group-hover:block">
435
+ {shortcut.label}
436
+ </span>
437
+ </span>
438
+ ))}
439
+ </div>
440
+
332
441
  {/* Timeline toggle */}
333
442
  {onToggleTimeline !== undefined && (
334
443
  <button
@@ -7,6 +7,8 @@ import {
7
7
  type ClipManifestClip,
8
8
  mergeTimelineElementsPreservingDowngrades,
9
9
  resolveStandaloneRootCompositionSrc,
10
+ shouldIgnorePlaybackShortcutEvent,
11
+ shouldIgnorePlaybackShortcutTarget,
10
12
  } from "./useTimelinePlayer";
11
13
 
12
14
  function createDocument(markup: string): Document {
@@ -32,6 +34,26 @@ function createClip(overrides: Partial<ClipManifestClip>): ClipManifestClip {
32
34
  };
33
35
  }
34
36
 
37
+ function mockTargetMatching(selectorNeedle: string): EventTarget {
38
+ return {
39
+ closest: (selector: string) => (selector.includes(selectorNeedle) ? ({} as Element) : null),
40
+ } as unknown as EventTarget;
41
+ }
42
+
43
+ function mockKeyboardEvent(
44
+ code: string,
45
+ overrides: Partial<Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "target">> = {},
46
+ ): Pick<KeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "code" | "target"> {
47
+ return {
48
+ altKey: false,
49
+ ctrlKey: false,
50
+ metaKey: false,
51
+ code,
52
+ target: mockTargetMatching("[data-missing]"),
53
+ ...overrides,
54
+ };
55
+ }
56
+
35
57
  describe("buildStandaloneRootTimelineElement", () => {
36
58
  it("includes selector and source metadata for standalone composition fallback clips", () => {
37
59
  expect(
@@ -153,3 +175,60 @@ describe("mergeTimelineElementsPreservingDowngrades", () => {
153
175
  ).toEqual([{ id: "hero", tag: "div", start: 0, duration: 4, track: 0 }]);
154
176
  });
155
177
  });
178
+
179
+ describe("shouldIgnorePlaybackShortcutTarget", () => {
180
+ it("ignores focused toolbar buttons so Space can activate the button itself", () => {
181
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("button"))).toBe(true);
182
+ });
183
+
184
+ it("ignores the seek slider so ArrowRight reaches the slider key handler", () => {
185
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[role='slider']"))).toBe(true);
186
+ });
187
+
188
+ it("allows non-interactive preview targets to use playback shortcuts", () => {
189
+ expect(shouldIgnorePlaybackShortcutTarget(mockTargetMatching("[data-missing]"))).toBe(false);
190
+ });
191
+ });
192
+
193
+ describe("shouldIgnorePlaybackShortcutEvent", () => {
194
+ it("ignores modified playback shortcuts so browser and app chords can handle them", () => {
195
+ expect(
196
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft", { altKey: true })),
197
+ ).toBe(true);
198
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyK", { ctrlKey: true }))).toBe(
199
+ true,
200
+ );
201
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyL", { metaKey: true }))).toBe(
202
+ true,
203
+ );
204
+ });
205
+
206
+ it("defers Arrow frame shortcuts while caption edit mode has selected words", () => {
207
+ const captionSelection = { isCaptionEditMode: true, selectedCaptionSegmentCount: 1 };
208
+
209
+ expect(
210
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowLeft"), captionSelection),
211
+ ).toBe(true);
212
+ expect(
213
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), captionSelection),
214
+ ).toBe(true);
215
+ expect(shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("KeyJ"), captionSelection)).toBe(
216
+ false,
217
+ );
218
+ });
219
+
220
+ it("allows Arrow frame shortcuts when captions are not selected", () => {
221
+ expect(
222
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
223
+ isCaptionEditMode: true,
224
+ selectedCaptionSegmentCount: 0,
225
+ }),
226
+ ).toBe(false);
227
+ expect(
228
+ shouldIgnorePlaybackShortcutEvent(mockKeyboardEvent("ArrowRight"), {
229
+ isCaptionEditMode: false,
230
+ selectedCaptionSegmentCount: 1,
231
+ }),
232
+ ).toBe(false);
233
+ });
234
+ });