@hyperframes/studio 0.8.105 → 0.8.107

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 (161) hide show
  1. package/dist/assets/{hyperframes-player-tnfCiVdR.js → hyperframes-player-h4mktSdm.js} +1 -1
  2. package/dist/assets/index-CM1xA_jD.js +394 -0
  3. package/dist/assets/{index-Ca6LZlVb.js → index-D8GKc2D9.js} +1 -1
  4. package/dist/assets/index-lrT-gfHa.css +1 -0
  5. package/dist/assets/{index-zpe332hb.js → index-zoqPmBF6.js} +1 -1
  6. package/dist/{chunk-QADHJFDF.js → chunk-4OZC426H.js} +13 -3
  7. package/dist/chunk-4OZC426H.js.map +1 -0
  8. package/dist/index.d.ts +381 -14
  9. package/dist/index.html +11 -2
  10. package/dist/index.js +9974 -9045
  11. package/dist/index.js.map +1 -1
  12. package/dist/player.js +1 -1
  13. package/dist/styles.css +1 -1
  14. package/package.json +9 -9
  15. package/src/App.tsx +5 -3
  16. package/src/agentToolsExport.test.ts +8 -0
  17. package/src/captions/keyboard.test.ts +12 -0
  18. package/src/captions/keyboard.ts +2 -1
  19. package/src/components/StudioHeader.dom.test.tsx +31 -9
  20. package/src/components/StudioHeader.tsx +4 -1
  21. package/src/components/StudioRightPanels.tsx +2 -0
  22. package/src/components/ThemeToggle.tsx +99 -0
  23. package/src/components/TimelineToolbar.test.tsx +27 -0
  24. package/src/components/TimelineToolbar.tsx +2 -5
  25. package/src/components/editor/DomEditOverlay.tsx +31 -5
  26. package/src/components/editor/PropertyPanel.tsx +1 -14
  27. package/src/components/editor/PropertyPanelFlat.tsx +3 -6
  28. package/src/components/editor/SnapToolbar.test.tsx +32 -1
  29. package/src/components/editor/SnapToolbar.tsx +3 -4
  30. package/src/components/editor/SourceEditor.tsx +2 -6
  31. package/src/components/editor/domEditOverlayStartGesture.ts +132 -117
  32. package/src/components/editor/fontAssets.test.ts +12 -22
  33. package/src/components/editor/gestureHoldsPreviewReload.test.tsx +338 -0
  34. package/src/components/editor/gsapAnimatesProperty.ts +3 -0
  35. package/src/components/editor/manualEdits.test.ts +4 -3
  36. package/src/components/editor/manualEditsDom.ts +58 -2
  37. package/src/components/editor/manualEditsDomPatches.ts +5 -1
  38. package/src/components/editor/manualOffsetDrag.ts +4 -1
  39. package/src/components/editor/marqueeCommit.test.tsx +69 -1
  40. package/src/components/editor/marqueeCommit.ts +6 -1
  41. package/src/components/editor/mediaAudioEdits.test.ts +212 -0
  42. package/src/components/editor/mediaAudioEdits.ts +253 -0
  43. package/src/components/editor/overlayFrameLoop.test.ts +27 -0
  44. package/src/components/editor/overlayFrameLoop.ts +21 -0
  45. package/src/components/editor/propertyPanelFlatMediaSection.test.tsx +286 -87
  46. package/src/components/editor/propertyPanelFlatMediaSection.tsx +17 -20
  47. package/src/components/editor/propertyPanelFlatProps.ts +1 -0
  48. package/src/components/editor/propertyPanelFont.test.tsx +80 -3
  49. package/src/components/editor/propertyPanelFont.tsx +69 -46
  50. package/src/components/editor/propertyPanelHelpers.ts +1 -2
  51. package/src/components/editor/propertyPanelMediaSection.tsx +17 -27
  52. package/src/components/editor/propertyPanelTypes.ts +2 -0
  53. package/src/components/editor/studioCodeTheme.test.ts +20 -0
  54. package/src/components/editor/studioCodeTheme.ts +99 -0
  55. package/src/components/editor/useDomEditNudge.test.tsx +165 -2
  56. package/src/components/editor/useDomEditNudge.ts +43 -25
  57. package/src/components/editor/useInlineTextEditing.tsx +1 -1
  58. package/src/components/editor/useMotionPathData.test.tsx +64 -0
  59. package/src/components/editor/useMotionPathData.ts +5 -1
  60. package/src/components/panels/SlideshowPanel.test.ts +27 -0
  61. package/src/components/panels/SlideshowPanel.tsx +12 -7
  62. package/src/contexts/DomEditContext.test.tsx +62 -0
  63. package/src/contexts/DomEditContext.tsx +94 -77
  64. package/src/hooks/appHotkeysDispatch.ts +2 -1
  65. package/src/hooks/canvasQuickSaves.test.tsx +72 -7
  66. package/src/hooks/domEditCommitTypes.ts +10 -0
  67. package/src/hooks/domEditPersistFailure.ts +12 -1
  68. package/src/hooks/domStyleCommit.fonts.test.ts +5 -4
  69. package/src/hooks/domStyleCommit.ts +2 -8
  70. package/src/hooks/gsapDragCommit.ts +3 -8
  71. package/src/hooks/gsapDragPositionCommit.ts +6 -1
  72. package/src/hooks/gsapEditOutcome.ts +2 -1
  73. package/src/hooks/gsapResizeElementSize.test.tsx +136 -0
  74. package/src/hooks/gsapResizeGeometrySweep.test.ts +3 -1
  75. package/src/hooks/gsapResizeIntercept.test.ts +3 -11
  76. package/src/hooks/gsapResizeIntercept.ts +6 -2
  77. package/src/hooks/gsapResizeMixedTween.test.ts +1 -2
  78. package/src/hooks/gsapRuntimeBridge.test.ts +55 -0
  79. package/src/hooks/gsapRuntimeBridge.ts +5 -3
  80. package/src/hooks/gsapRuntimeKeyframes.ts +1 -3
  81. package/src/hooks/inspectorSaveReload.test.tsx +107 -0
  82. package/src/hooks/previewPromotionSelection.test.tsx +180 -0
  83. package/src/hooks/useAppHotkeys.previewForwarding.test.tsx +16 -1
  84. package/src/hooks/useAppHotkeys.test.ts +49 -0
  85. package/src/hooks/useAppHotkeys.textEditing.test.tsx +1 -1
  86. package/src/hooks/useAppHotkeys.ts +4 -5
  87. package/src/hooks/useAutomationSelectionKeyboard.test.tsx +17 -0
  88. package/src/hooks/useAutomationSelectionKeyboard.ts +3 -9
  89. package/src/hooks/useClipboard.source.test.ts +99 -19
  90. package/src/hooks/useClipboard.ts +145 -124
  91. package/src/hooks/useDomEditAttributeCommits.ts +71 -29
  92. package/src/hooks/useDomEditCommits.test.tsx +172 -92
  93. package/src/hooks/useDomEditCommits.ts +2 -0
  94. package/src/hooks/useDomEditCommitsHelpers.ts +4 -6
  95. package/src/hooks/useDomEditPersist.ts +28 -13
  96. package/src/hooks/useDomEditPositionPatchCommit.ts +2 -0
  97. package/src/hooks/useDomEditSession.test.tsx +76 -72
  98. package/src/hooks/useDomEditSession.ts +9 -46
  99. package/src/hooks/useDomEditTextCommits.ts +4 -8
  100. package/src/hooks/useDomGeometryCommits.ts +11 -2
  101. package/src/hooks/useEditHistoryActions.paint.test.tsx +122 -3
  102. package/src/hooks/useGsapAwareEditing.ts +6 -6
  103. package/src/hooks/useGsapScriptCommits.test.tsx +19 -0
  104. package/src/hooks/useGsapScriptCommits.ts +5 -4
  105. package/src/hooks/useGsapTweenCache.ts +1 -2
  106. package/src/hooks/useInlineTextEdit.test.tsx +27 -0
  107. package/src/hooks/useInlineTextEdit.ts +21 -5
  108. package/src/hooks/useKeyframeKeyboard.test.tsx +15 -0
  109. package/src/hooks/useKeyframeKeyboard.ts +2 -8
  110. package/src/hooks/useLivePreviewIframe.ts +18 -0
  111. package/src/hooks/usePreviewPersistence.history.test.tsx +47 -0
  112. package/src/hooks/usePreviewPersistence.ts +8 -5
  113. package/src/hooks/useRenderClipContent.ts +3 -3
  114. package/src/hooks/useTrackPendingTimelineEdit.ts +2 -6
  115. package/src/index.ts +9 -0
  116. package/src/livePreviewIframeExport.test.tsx +33 -0
  117. package/src/main.tsx +4 -1
  118. package/src/player/components/AudioWaveform.test.tsx +36 -15
  119. package/src/player/components/AudioWaveform.tsx +24 -7
  120. package/src/player/components/Timeline.test.ts +0 -21
  121. package/src/player/components/Timeline.tsx +0 -1
  122. package/src/player/components/TimelineClip.test.tsx +38 -0
  123. package/src/player/components/TimelineClip.tsx +14 -6
  124. package/src/player/components/TimelineClipFades.test.tsx +376 -74
  125. package/src/player/components/TimelineClipFades.tsx +368 -137
  126. package/src/player/components/timelineLayout.ts +0 -28
  127. package/src/player/components/useTimelineKeyboardActor.ts +1 -1
  128. package/src/player/hooks/timelinePlayerTestHarness.ts +27 -0
  129. package/src/player/hooks/usePlaybackKeyboard.test.ts +43 -0
  130. package/src/player/hooks/useShadowPreviewReload.ts +72 -5
  131. package/src/player/lib/automationStoreSync.test.ts +18 -1
  132. package/src/player/lib/automationStoreSync.ts +29 -17
  133. package/src/player/lib/playbackShortcuts.ts +3 -7
  134. package/src/player/lib/timelineElementHelpers.test.ts +16 -0
  135. package/src/player/lib/timelineElementHelpers.ts +16 -2
  136. package/src/player/lib/timelineViewportBudgets.test.ts +2 -0
  137. package/src/player/lib/timelineViewportBudgets.ts +4 -0
  138. package/src/player/sceneSwap.ts +18 -0
  139. package/src/styles/components.css +26 -0
  140. package/src/styles/studio.css +6 -0
  141. package/src/styles/theme.css +10 -0
  142. package/src/utils/clipboardPayload.ts +10 -1
  143. package/src/utils/gsapSoftReload.ts +3 -2
  144. package/src/utils/gsapUndoRestore.browser.test.ts +94 -0
  145. package/src/utils/gsapUndoRestore.test.ts +145 -1
  146. package/src/utils/gsapUndoRestore.ts +70 -21
  147. package/src/utils/sourcePatcher.ts +1 -1
  148. package/src/utils/studioFontHelpers.ts +2 -23
  149. package/src/utils/studioHelpers.test.ts +30 -0
  150. package/src/utils/studioHelpers.ts +18 -4
  151. package/src/utils/studioPendingEdits.test.ts +63 -0
  152. package/src/utils/studioPendingEdits.ts +37 -7
  153. package/src/utils/studioTheme.test.tsx +141 -0
  154. package/src/utils/studioTheme.ts +34 -0
  155. package/src/utils/studioUiPreferences.ts +3 -0
  156. package/src/utils/typingTarget.test.ts +21 -1
  157. package/src/utils/typingTarget.ts +8 -0
  158. package/src/webmcp/useStudioAgentTools.ts +3 -1
  159. package/dist/assets/index-Cre9X4RS.css +0 -1
  160. package/dist/assets/index-DEDhPmPD.js +0 -399
  161. package/dist/chunk-QADHJFDF.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { StudioFileConflictError, type StudioSaveDrainResult } from "./studioSaveDiagnostics";
2
+ import { isTypingTarget } from "./typingTarget";
2
3
 
3
4
  const STUDIO_FLUSH_PENDING_EDITS_EVENT = "hf-studio-flush-pending-edits";
4
5
 
@@ -29,16 +30,34 @@ function inspectDrainFailures(results: PromiseSettledResult<unknown>[]): {
29
30
 
30
31
  function focusedField(): HTMLElement | null {
31
32
  const active = document.activeElement;
32
- return active instanceof HTMLElement &&
33
- active.matches('input, textarea, select, [contenteditable="true"], [role="textbox"]')
34
- ? active
35
- : null;
33
+ return active instanceof HTMLElement && isTypingTarget(active) ? active : null;
36
34
  }
37
35
 
38
36
  export function hasStudioPendingEdits(): boolean {
39
37
  return pendingEditPromises.size > 0 || focusedField() !== null;
40
38
  }
41
39
 
40
+ export function isStudioEditSaving(): boolean {
41
+ return pendingEditPromises.size > 0;
42
+ }
43
+
44
+ export function afterStudioPendingEdits(run: () => void): () => void {
45
+ let waiting = true;
46
+ const check = () => {
47
+ if (!waiting) return;
48
+ if (isStudioEditSaving()) {
49
+ void Promise.allSettled([...pendingEditPromises]).then(check);
50
+ return;
51
+ }
52
+ waiting = false;
53
+ run();
54
+ };
55
+ check();
56
+ return () => {
57
+ waiting = false;
58
+ };
59
+ }
60
+
42
61
  export function trackStudioPendingEdit(
43
62
  result: Promise<unknown> | unknown,
44
63
  ): Promise<unknown> | undefined {
@@ -52,6 +71,16 @@ export function trackStudioPendingEdit(
52
71
  return promise;
53
72
  }
54
73
 
74
+ export function trackedStudioEdit<Args extends unknown[], R>(
75
+ edit: (...args: Args) => R,
76
+ ): (...args: Args) => R {
77
+ return (...args) => {
78
+ const result = edit(...args);
79
+ if (result instanceof Promise) trackStudioPendingEdit(result);
80
+ return result;
81
+ };
82
+ }
83
+
55
84
  export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrainResult> {
56
85
  const active = focusedField();
57
86
  if (active) {
@@ -65,15 +94,16 @@ export async function flushStudioPendingEdits(): Promise<StudioPendingEditsDrain
65
94
  window.dispatchEvent(
66
95
  new CustomEvent<StudioFlushPendingEditsDetail>(STUDIO_FLUSH_PENDING_EDITS_EVENT, { detail }),
67
96
  );
97
+ let conflict: StudioFileConflictError | undefined;
68
98
  let firstFailure: PromiseRejectedResult | undefined;
69
99
  while (detail.promises.length > 0 || pendingEditPromises.size > 0) {
70
100
  const promises = [...detail.promises, ...pendingEditPromises];
71
101
  detail.promises = [];
72
- const results = await Promise.allSettled(promises);
73
- const batchFailures = inspectDrainFailures(results);
74
- if (batchFailures.conflict) return { status: "conflict", error: batchFailures.conflict };
102
+ const batchFailures = inspectDrainFailures(await Promise.allSettled(promises));
103
+ conflict ??= batchFailures.conflict;
75
104
  firstFailure ??= batchFailures.firstFailure;
76
105
  }
106
+ if (conflict) return { status: "conflict", error: conflict };
77
107
  return firstFailure ? { status: "failed", error: firstFailure.reason } : { status: "clean" };
78
108
  }
79
109
 
@@ -0,0 +1,141 @@
1
+ // @vitest-environment happy-dom
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { act } from "react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { ThemeToggle } from "../components/ThemeToggle";
7
+ import { cleanupMounted, mountHost } from "../components/ui/mountHost.testHelpers";
8
+ import { savedStudioTheme, shownStudioTheme } from "./studioTheme";
9
+
10
+ const KEY = "hf-studio-ui-preferences";
11
+ const page = new DOMParser().parseFromString(
12
+ readFileSync(path.join(__dirname, "../../index.html"), "utf8"),
13
+ "text/html",
14
+ );
15
+ const scripts = [...page.querySelectorAll("script")];
16
+ const boot = scripts.find((script) => !script.type)?.textContent ?? "";
17
+
18
+ function store(theme: unknown) {
19
+ localStorage.setItem(KEY, JSON.stringify({ theme }));
20
+ }
21
+
22
+ function boots(): "light" | "dark" {
23
+ new Function(boot)();
24
+ return shownStudioTheme();
25
+ }
26
+
27
+ afterEach(() => {
28
+ cleanupMounted();
29
+ localStorage.clear();
30
+ delete document.documentElement.dataset.theme;
31
+ });
32
+
33
+ describe("Studio's theme", () => {
34
+ it.each([["light"], ["dark"], [undefined], ["neon"]])(
35
+ "boots to the theme the owner picks (saved %s)",
36
+ (saved) => {
37
+ store(saved);
38
+ expect(boots()).toBe(savedStudioTheme());
39
+ },
40
+ );
41
+
42
+ it.each([["null"], ["{not json"], ['"dark"']])("boots to the owner's pick from %s", (raw) => {
43
+ localStorage.setItem(KEY, raw);
44
+ expect(boots()).toBe(savedStudioTheme());
45
+ });
46
+
47
+ it("opens light the first time", () => {
48
+ expect(boots()).toBe("light");
49
+ expect(savedStudioTheme()).toBe("light");
50
+ });
51
+
52
+ it("runs the boot script before the app's module", () => {
53
+ expect(boot).toContain(`"${KEY}"`);
54
+ expect(scripts.findIndex((script) => !script.type)).toBeLessThan(
55
+ scripts.findIndex((script) => script.type === "module"),
56
+ );
57
+ });
58
+
59
+ it("flips the document between light and dark and keeps the choice", async () => {
60
+ store("dark");
61
+ const host = mountHost(<ThemeToggle />);
62
+ const button = () => host.querySelector("button")!;
63
+ expect(button().getAttribute("aria-label")).toBe("Switch to light theme");
64
+
65
+ await act(async () => button().click());
66
+ expect(document.documentElement.dataset.theme).toBe("paper");
67
+ expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("light");
68
+ expect(button().getAttribute("aria-label")).toBe("Switch to dark theme");
69
+
70
+ await act(async () => button().click());
71
+ expect(document.documentElement.dataset.theme).toBeUndefined();
72
+ expect(JSON.parse(localStorage.getItem(KEY)!).theme).toBe("dark");
73
+ });
74
+
75
+ it("follows the theme on screen, not only its own clicks", async () => {
76
+ store("light");
77
+ const host = mountHost(<ThemeToggle />);
78
+ expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to light theme");
79
+ await act(async () => {
80
+ document.documentElement.dataset.theme = "paper";
81
+ });
82
+ expect(host.querySelector("button")!.getAttribute("aria-label")).toBe("Switch to dark theme");
83
+ });
84
+ });
85
+
86
+ describe("the reveal", () => {
87
+ function stubTransition() {
88
+ const animate = vi.fn();
89
+ Object.assign(document.documentElement, { animate });
90
+ const markers: (string | undefined)[] = [];
91
+ const finish: (() => void)[] = [];
92
+ const start = vi.fn((update: () => void) => {
93
+ markers.push(document.documentElement.dataset.themeReveal);
94
+ update();
95
+ return { ready: Promise.resolve(), finished: new Promise<void>((done) => finish.push(done)) };
96
+ });
97
+ Object.assign(document, { startViewTransition: start });
98
+ return { animate, start, markers, finish };
99
+ }
100
+
101
+ afterEach(() => {
102
+ Reflect.deleteProperty(document, "startViewTransition");
103
+ Reflect.deleteProperty(document.documentElement, "animate");
104
+ vi.unstubAllGlobals();
105
+ });
106
+
107
+ it("grows from the button's centre when the keyboard presses it, marked while it runs", async () => {
108
+ const { animate, markers, finish } = stubTransition();
109
+ vi.stubGlobal("innerWidth", 1000);
110
+ vi.stubGlobal("innerHeight", 500);
111
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
112
+ button.getBoundingClientRect = () => ({ left: 890, top: 10, width: 20, height: 20 }) as DOMRect;
113
+ await act(async () => {
114
+ button.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 }));
115
+ });
116
+ expect(markers).toEqual([""]);
117
+ expect(animate.mock.calls[0]![0].clipPath[1]).toMatch(/^circle\(129\.0\d*% at 90% 4%\)$/);
118
+ await act(async () => finish[0]!());
119
+ expect(document.documentElement.dataset.themeReveal).toBeUndefined();
120
+ });
121
+
122
+ it("keeps the marker for a second reveal that starts before the first ends", async () => {
123
+ const { finish } = stubTransition();
124
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
125
+ await act(async () => button.click());
126
+ await act(async () => button.click());
127
+ await act(async () => finish[0]!());
128
+ expect(document.documentElement.dataset.themeReveal).toBe("");
129
+ await act(async () => finish[1]!());
130
+ expect(document.documentElement.dataset.themeReveal).toBeUndefined();
131
+ });
132
+
133
+ it("switches without the reveal under reduced motion", async () => {
134
+ const { start } = stubTransition();
135
+ vi.stubGlobal("matchMedia", (query: string) => ({ matches: query.includes("reduce") }));
136
+ const button = mountHost(<ThemeToggle />).querySelector("button")!;
137
+ await act(async () => button.click());
138
+ expect(start).not.toHaveBeenCalled();
139
+ expect(document.documentElement.dataset.theme).toBe("paper");
140
+ });
141
+ });
@@ -0,0 +1,34 @@
1
+ // Studio's own app opens on the toggle's saved choice, else light. Light is `data-theme="paper"`
2
+ // on the document element, the hook theme.css keys on; dark is no attribute. index.html applies
3
+ // `savedStudioTheme` before the first paint; studioTheme.test.tsx holds the two together.
4
+ import { useSyncExternalStore } from "react";
5
+ import {
6
+ readStudioUiPreferences,
7
+ writeStudioUiPreferences,
8
+ type StudioTheme,
9
+ } from "./studioUiPreferences";
10
+
11
+ export function savedStudioTheme(): StudioTheme {
12
+ return readStudioUiPreferences().theme ?? "light";
13
+ }
14
+
15
+ export function shownStudioTheme(): StudioTheme {
16
+ return document.documentElement.dataset.theme === "paper" ? "light" : "dark";
17
+ }
18
+
19
+ export function setStudioTheme(theme: StudioTheme): void {
20
+ writeStudioUiPreferences({ theme });
21
+ const root = document.documentElement;
22
+ if (theme === "light") root.dataset.theme = "paper";
23
+ else delete root.dataset.theme;
24
+ }
25
+
26
+ function subscribe(listener: () => void): () => void {
27
+ const observer = new MutationObserver(listener);
28
+ observer.observe(document.documentElement, { attributeFilter: ["data-theme"] });
29
+ return () => observer.disconnect();
30
+ }
31
+
32
+ export function useShownStudioTheme(): StudioTheme {
33
+ return useSyncExternalStore(subscribe, shownStudioTheme);
34
+ }
@@ -2,6 +2,7 @@ import type { SerializedDockview } from "dockview-react";
2
2
  import { parseDockLayout } from "../components/dock/dockLayoutSchema";
3
3
 
4
4
  export type TimelineTimeDisplayMode = "time" | "frame";
5
+ export type StudioTheme = "light" | "dark";
5
6
 
6
7
  export interface StudioUiPreferences {
7
8
  timelineVisible?: boolean;
@@ -41,6 +42,7 @@ export interface StudioUiPreferences {
41
42
  * intentionally scoped to one mount.
42
43
  */
43
44
  agentToolsEnabled?: boolean;
45
+ theme?: StudioTheme;
44
46
  /** The dock's serialized panel tree; parsed by `parseDockLayout` on read. */
45
47
  dockLayout?: SerializedDockview;
46
48
  }
@@ -128,6 +130,7 @@ function readStorage(storage: Storage | null, key: string): StudioUiPreferences
128
130
  if (typeof parsed.rippleEditEnabled === "boolean") {
129
131
  preferences.rippleEditEnabled = parsed.rippleEditEnabled;
130
132
  }
133
+ if (parsed.theme === "light" || parsed.theme === "dark") preferences.theme = parsed.theme;
131
134
  if (parsed.timeDisplayMode === "time" || parsed.timeDisplayMode === "frame") {
132
135
  preferences.timeDisplayMode = parsed.timeDisplayMode;
133
136
  }
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
3
  import { afterEach, describe, expect, it } from "vitest";
4
- import { isTypingTarget } from "./typingTarget";
4
+ import { isTypingTarget, ownsPlainKeys } from "./typingTarget";
5
5
  import { isEditableTarget } from "./timelineDiscovery";
6
6
 
7
7
  afterEach(() => {
@@ -41,6 +41,13 @@ describe("isTypingTarget", () => {
41
41
  expect(isTypingTarget(mount('<div role="combobox"></div>'))).toBe(true);
42
42
  });
43
43
 
44
+ it("gives a focused slider its plain keys but leaves Cmd shortcuts to the app", () => {
45
+ const slider = mount('<div role="slider" tabindex="0"></div>');
46
+ expect(ownsPlainKeys(slider)).toBe(true);
47
+ // Undo, copy and group still reach the app while a slider has focus.
48
+ expect(isTypingTarget(slider)).toBe(false);
49
+ });
50
+
44
51
  it("leaves the keys alone for anything that is not being typed into", () => {
45
52
  expect(isTypingTarget(mount("<div>plain</div>"))).toBe(false);
46
53
  expect(isTypingTarget(mount("<button>press</button>"))).toBe(false);
@@ -52,3 +59,16 @@ describe("isTypingTarget", () => {
52
59
  expect(isTypingTarget({} as EventTarget)).toBe(false);
53
60
  });
54
61
  });
62
+
63
+ describe("ownsPlainKeys", () => {
64
+ it("is true for a native player with controls and for anything typing claims", () => {
65
+ expect(ownsPlainKeys(mount("<video controls></video>"))).toBe(true);
66
+ expect(ownsPlainKeys(mount("<audio controls></audio>"))).toBe(true);
67
+ expect(ownsPlainKeys(mount("<input />"))).toBe(true);
68
+ });
69
+
70
+ it("is false for a player without controls and for plain elements", () => {
71
+ expect(ownsPlainKeys(mount("<video></video>"))).toBe(false);
72
+ expect(ownsPlainKeys(mount("<div></div>"))).toBe(false);
73
+ });
74
+ });
@@ -21,6 +21,14 @@ export function isTypingTarget(target: EventTarget | null): boolean {
21
21
  return element.closest(TYPING_SELECTOR) !== null;
22
22
  }
23
23
 
24
+ /** Plain keys (arrows, Space, Delete) also belong to a focused slider or player; Cmd shortcuts still do not. */
25
+ export function ownsPlainKeys(target: EventTarget | null): boolean {
26
+ return isTypingTarget(target) || asElement(target)?.closest(KEY_OWNING_CONTROLS) != null;
27
+ }
28
+
29
+ // A slider steps and a native player seeks and plays with these keys themselves.
30
+ const KEY_OWNING_CONTROLS = "[role='slider'],video[controls],audio[controls]";
31
+
24
32
  /**
25
33
  * Things a keystroke belongs to rather than to a shortcut. `contenteditable` is
26
34
  * matched by value as well, for a host whose own property is not yet true.
@@ -111,7 +111,9 @@ export interface StudioAgentToolsDeps
111
111
  * a snapshot, which is what lets the list be built once and still see live
112
112
  * state. That is the whole point of the ref: see the registration note below.
113
113
  */
114
- function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
114
+ export function buildStudioTools(depsRef: {
115
+ readonly current: StudioAgentToolsDeps;
116
+ }): ModelContextTool[] {
115
117
  /**
116
118
  * `execute` for a tool whose handler takes the caller's abort signal.
117
119
  *