@hyperframes/studio 0.8.16 → 0.8.17

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 (53) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DejBDgXB.js} +1 -1
  2. package/dist/assets/index-BX3KHhGX.js +71 -0
  3. package/dist/assets/{index-D8o3ZIo2.js → index-CU6o8PuW.js} +128 -128
  4. package/dist/assets/{index-Cf-mbMRL.js → index-CklNVmi3.js} +1 -1
  5. package/dist/assets/{index-YmetcS6L.js → index-uu4Zd3BU.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1638 -1280
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -7
  11. package/src/components/EditorShell.tsx +4 -0
  12. package/src/components/editor/DomEditCropHandles.test.tsx +1 -1
  13. package/src/components/editor/DomEditCropHandles.tsx +1 -1
  14. package/src/components/editor/DomEditOverlay.tsx +1 -1
  15. package/src/components/editor/DomEditSelectionChrome.tsx +1 -1
  16. package/src/components/editor/anchoredResizeCommitFeedsOffset.test.ts +38 -4
  17. package/src/components/editor/propertyPanelCommitField.tsx +1 -1
  18. package/src/components/editor/propertyPanelFlatLayoutSection.tsx +3 -3
  19. package/src/components/editor/propertyPanelFlatMaskInsetRows.tsx +1 -1
  20. package/src/components/editor/propertyPanelFlatMediaSection.tsx +1 -1
  21. package/src/components/editor/propertyPanelFlatPrimitives.tsx +1 -1
  22. package/src/components/editor/propertyPanelFlatStyleSections.tsx +8 -8
  23. package/src/components/editor/propertyPanelMediaSection.tsx +1 -1
  24. package/src/components/editor/propertyPanelPrimitives.tsx +1 -1
  25. package/src/components/editor/propertyPanelStyleSections.tsx +1 -1
  26. package/src/components/editor/propertyPanelTypes.ts +1 -1
  27. package/src/components/editor/useDomEditOverlayGestures.ts +8 -2
  28. package/src/components/editor/useInspectorGestureTransaction.ts +3 -3
  29. package/src/hooks/domEditCommitRunner.ts +47 -0
  30. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  31. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  32. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  33. package/src/hooks/useDomEditTextCommits.ts +17 -9
  34. package/src/hooks/useDomEditWiring.ts +1 -1
  35. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  36. package/src/hooks/useDomGeometryCommits.ts +10 -2
  37. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  38. package/src/hooks/useElementLifecycleOps.ts +11 -5
  39. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  40. package/src/utils/studioUiPreferences.ts +11 -0
  41. package/src/webmcp/StudioAgentTools.tsx +46 -0
  42. package/src/webmcp/handles.test.ts +130 -0
  43. package/src/webmcp/handles.ts +129 -0
  44. package/src/webmcp/polyfill.test.ts +98 -0
  45. package/src/webmcp/polyfill.ts +60 -0
  46. package/src/webmcp/registrar.test.ts +150 -0
  47. package/src/webmcp/registrar.ts +115 -0
  48. package/src/webmcp/toolResult.ts +67 -0
  49. package/src/webmcp/tools/lookTools.test.ts +225 -0
  50. package/src/webmcp/tools/lookTools.ts +201 -0
  51. package/src/webmcp/types.ts +90 -0
  52. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  53. package/src/webmcp/useStudioAgentTools.ts +119 -0
@@ -0,0 +1,221 @@
1
+ // @vitest-environment jsdom
2
+ import { act } from "react";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { mountReactHarness } from "../hooks/domSelectionTestHarness";
5
+ import { writeStudioUiPreferences } from "../utils/studioUiPreferences";
6
+ import { useStudioAgentTools, type StudioAgentToolsDeps } from "./useStudioAgentTools";
7
+ import type { ModelContext, ModelContextRegisterToolOptions, ModelContextTool } from "./types";
8
+ import type { StudioLookSnapshot } from "./tools/lookTools";
9
+
10
+ const trackEvent = vi.hoisted(() => vi.fn());
11
+ vi.mock("../telemetry/client", () => ({ trackEvent }));
12
+
13
+ Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true);
14
+
15
+ let cleanup: (() => void) | null = null;
16
+
17
+ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
18
+ return {
19
+ projectId: "demo",
20
+ compositionPath: "index.html",
21
+ currentTime: 0,
22
+ duration: 10,
23
+ isPlaying: false,
24
+ elements: [],
25
+ selection: null,
26
+ selectionAnimationCount: 0,
27
+ history: { canUndo: false, canRedo: false, undoLabel: null, redoLabel: null },
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ /** Install a fake `document.modelContext` and report what got registered. */
33
+ function installModelContext() {
34
+ const registered: ModelContextTool[] = [];
35
+ const registerTool = vi.fn(
36
+ async (tool: ModelContextTool, _options?: ModelContextRegisterToolOptions) => {
37
+ registered.push(tool);
38
+ },
39
+ );
40
+ const modelContext: ModelContext = { registerTool };
41
+ Object.defineProperty(document, "modelContext", {
42
+ value: modelContext,
43
+ configurable: true,
44
+ writable: true,
45
+ });
46
+ return { registered, registerTool };
47
+ }
48
+
49
+ function removeModelContext() {
50
+ Reflect.deleteProperty(document, "modelContext");
51
+ }
52
+
53
+ function mountTools(deps: StudioAgentToolsDeps) {
54
+ function Probe({ current }: { current: StudioAgentToolsDeps }) {
55
+ useStudioAgentTools(current);
56
+ return null;
57
+ }
58
+ const root = mountReactHarness(<Probe current={deps} />);
59
+ cleanup = () => act(() => root.unmount());
60
+ return {
61
+ rerenderWith(next: StudioAgentToolsDeps) {
62
+ act(() => root.render(<Probe current={next} />));
63
+ },
64
+ };
65
+ }
66
+
67
+ beforeEach(() => {
68
+ window.localStorage.clear();
69
+ trackEvent.mockReset();
70
+ });
71
+
72
+ afterEach(() => {
73
+ cleanup?.();
74
+ cleanup = null;
75
+ removeModelContext();
76
+ window.localStorage.clear();
77
+ vi.restoreAllMocks();
78
+ });
79
+
80
+ describe("useStudioAgentTools", () => {
81
+ it("registers the tool set once on mount", async () => {
82
+ const { registered } = installModelContext();
83
+
84
+ await act(async () => {
85
+ mountTools({ getSnapshot: () => snapshot() });
86
+ });
87
+
88
+ expect(registered.map((tool) => tool.name)).toEqual(["studio_look"]);
89
+ expect(trackEvent).toHaveBeenCalledWith("webmcp.native_present");
90
+ });
91
+
92
+ it("does not re-register when the deps object changes identity", async () => {
93
+ // The regression test for the whole design. The DomEdit actions object
94
+ // changes identity on nearly every interaction; if registration depended on
95
+ // it, the signal would abort and unregister the tools each time.
96
+ const { registerTool } = installModelContext();
97
+
98
+ let harness: ReturnType<typeof mountTools> | null = null;
99
+ await act(async () => {
100
+ harness = mountTools({ getSnapshot: () => snapshot() });
101
+ });
102
+ expect(registerTool).toHaveBeenCalledTimes(1);
103
+
104
+ await act(async () => {
105
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 5 }) });
106
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 6 }) });
107
+ });
108
+
109
+ expect(registerTool).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it("executes against the LATEST deps, not the ones present at registration", async () => {
113
+ // The other half of the ref: registering once must not freeze the state the
114
+ // tools read, or every answer after the first render would be stale.
115
+ const { registered } = installModelContext();
116
+
117
+ let harness: ReturnType<typeof mountTools> | null = null;
118
+ await act(async () => {
119
+ harness = mountTools({ getSnapshot: () => snapshot({ currentTime: 1 }) });
120
+ });
121
+
122
+ await act(async () => {
123
+ harness?.rerenderWith({ getSnapshot: () => snapshot({ currentTime: 42 }) });
124
+ });
125
+
126
+ const look = registered[0];
127
+ if (!look) throw new Error("expected studio_look to be registered");
128
+ const result = (await look.execute({}, { signal: new AbortController().signal })) as {
129
+ ok: boolean;
130
+ playhead: number;
131
+ };
132
+
133
+ expect(result.ok).toBe(true);
134
+ expect(result.playhead).toBe(42);
135
+ });
136
+
137
+ it("unregisters on unmount by aborting the registration signal", async () => {
138
+ const { registerTool } = installModelContext();
139
+
140
+ await act(async () => {
141
+ mountTools({ getSnapshot: () => snapshot() });
142
+ });
143
+ const signal = registerTool.mock.calls[0]?.[1]?.signal;
144
+ expect(signal?.aborted).toBe(false);
145
+
146
+ cleanup?.();
147
+ cleanup = null;
148
+
149
+ expect(signal?.aborted).toBe(true);
150
+ });
151
+
152
+ it("registers nothing when the browser has no WebMCP", async () => {
153
+ removeModelContext();
154
+
155
+ await act(async () => {
156
+ mountTools({ getSnapshot: () => snapshot() });
157
+ });
158
+
159
+ // The assertion is that mounting did not throw; a browser without the API
160
+ // must still boot Studio.
161
+ expect(document).not.toHaveProperty("modelContext");
162
+ });
163
+
164
+ it("registers nothing when the preference is turned off", async () => {
165
+ writeStudioUiPreferences({ agentToolsEnabled: false });
166
+ const { registerTool } = installModelContext();
167
+
168
+ await act(async () => {
169
+ mountTools({ getSnapshot: () => snapshot() });
170
+ });
171
+
172
+ expect(registerTool).not.toHaveBeenCalled();
173
+ });
174
+
175
+ it("registers when the preference is absent, because on is the default", async () => {
176
+ const { registerTool } = installModelContext();
177
+
178
+ await act(async () => {
179
+ mountTools({ getSnapshot: () => snapshot() });
180
+ });
181
+
182
+ expect(registerTool).toHaveBeenCalledTimes(1);
183
+ });
184
+
185
+ it("reports a non-abort registration failure through production telemetry", async () => {
186
+ const { registerTool } = installModelContext();
187
+ registerTool.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
188
+
189
+ await act(async () => {
190
+ mountTools({ getSnapshot: () => snapshot() });
191
+ });
192
+
193
+ expect(trackEvent).toHaveBeenCalledWith("webmcp_registration_failed", {
194
+ error_name: "NotAllowedError",
195
+ tool_name: "studio_look",
196
+ });
197
+ });
198
+
199
+ it("reports a tool that throws as an internal failure instead of rejecting", async () => {
200
+ const { registered } = installModelContext();
201
+
202
+ await act(async () => {
203
+ mountTools({
204
+ getSnapshot: () => {
205
+ throw new TypeError("handler signature moved");
206
+ },
207
+ });
208
+ });
209
+ vi.spyOn(console, "error").mockImplementation(() => {});
210
+
211
+ const look = registered[0];
212
+ if (!look) throw new Error("expected studio_look to be registered");
213
+ const result = (await look.execute({}, { signal: new AbortController().signal })) as {
214
+ ok: boolean;
215
+ kind: string;
216
+ };
217
+
218
+ expect(result.ok).toBe(false);
219
+ expect(result.kind).toBe("internal");
220
+ });
221
+ });
@@ -0,0 +1,119 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { trackEvent } from "../telemetry/client";
3
+ import { readStudioUiPreferences } from "../utils/studioUiPreferences";
4
+ import { makeStudioDebugLogger } from "../utils/studioDebug";
5
+ import { loadModelContextPolyfill } from "./polyfill";
6
+ import { registerStudioTools, type ToolRegistrationReport } from "./registrar";
7
+ import { runToolBody, type ToolResult } from "./toolResult";
8
+ import { getModelContext, type ModelContext, type ModelContextTool } from "./types";
9
+ import {
10
+ buildStudioLook,
11
+ STUDIO_LOOK_DESCRIPTION,
12
+ STUDIO_LOOK_INPUT_SCHEMA,
13
+ type StudioLook,
14
+ type StudioLookInput,
15
+ type StudioLookSnapshot,
16
+ } from "./tools/lookTools";
17
+
18
+ const log = makeStudioDebugLogger("webmcp");
19
+
20
+ function reportRegistration(report: ToolRegistrationReport, native: boolean): void {
21
+ log("registered", { native, ...report });
22
+ for (const failure of report.failed) {
23
+ trackEvent("webmcp_registration_failed", {
24
+ error_name: failure.name,
25
+ tool_name: failure.tool,
26
+ });
27
+ }
28
+ }
29
+
30
+ export interface StudioAgentToolsDeps {
31
+ /** Read Studio's current state. Called per tool invocation, never cached. */
32
+ getSnapshot: () => StudioLookSnapshot;
33
+ }
34
+
35
+ /**
36
+ * Build the tool list once.
37
+ *
38
+ * Every `execute` reads `depsRef.current` at CALL time rather than closing over
39
+ * a snapshot, which is what lets the list be built once and still see live
40
+ * state. That is the whole point of the ref: see the registration note below.
41
+ */
42
+ function buildStudioTools(depsRef: { readonly current: StudioAgentToolsDeps }): ModelContextTool[] {
43
+ return [
44
+ {
45
+ name: "studio_look",
46
+ title: "Look at the composition",
47
+ description: STUDIO_LOOK_DESCRIPTION,
48
+ inputSchema: STUDIO_LOOK_INPUT_SCHEMA,
49
+ annotations: {
50
+ readOnlyHint: true,
51
+ // The labels, text and ids come from the user's composition, which can
52
+ // contain anything. This is a hint to the agent, not a sanitiser.
53
+ untrustedContentHint: true,
54
+ },
55
+ execute: (input): Promise<ToolResult<StudioLook>> =>
56
+ runToolBody("studio_look", async () =>
57
+ buildStudioLook(depsRef.current.getSnapshot(), input as StudioLookInput),
58
+ ),
59
+ },
60
+ ];
61
+ }
62
+
63
+ /**
64
+ * Register Studio's tools with the browser, exactly once per mount.
65
+ *
66
+ * The effect has an EMPTY dependency array on purpose, and the deps live in a
67
+ * ref that every render refreshes. The obvious alternative — depend on the
68
+ * handlers — re-runs on nearly every interaction, because the DomEdit actions
69
+ * object changes identity whenever the selection or the element list does.
70
+ * Re-running means the registration signal aborts and unregisters everything,
71
+ * `toolchange` fires constantly so a connected agent watches the tool list
72
+ * churn, and the spec warns that a quick unregister-then-reregister can apply
73
+ * an old call's arguments against the new schema.
74
+ *
75
+ * `useStudioTestHooks` carries a comment about the same class of bug already hit
76
+ * in this codebase, where effect teardown revoked a lease moments after it was
77
+ * taken because writing state changed the effect's dependency identities.
78
+ *
79
+ * Any fallback must be awaited inside this effect before registration and then
80
+ * re-read here. Installing one from a sibling effect would race this mount-only
81
+ * lookup. Hot-module replacement can still create a brief unregister/register
82
+ * window in development; production has one document-scoped registration.
83
+ */
84
+ export function useStudioAgentTools(deps: StudioAgentToolsDeps): void {
85
+ const depsRef = useRef(deps);
86
+ depsRef.current = deps;
87
+
88
+ // eslint-disable-next-line no-restricted-syntax
89
+ useEffect(() => {
90
+ if (readStudioUiPreferences().agentToolsEnabled === false) {
91
+ log("skipped", { why: "disabled by preference" });
92
+ return;
93
+ }
94
+
95
+ const controller = new AbortController();
96
+
97
+ void (async () => {
98
+ const native: ModelContext | null = getModelContext();
99
+ if (native) trackEvent("webmcp.native_present");
100
+ // Native browsers never download the polyfill.
101
+ const modelContext = native ?? (await loadModelContextPolyfill());
102
+ if (!modelContext) {
103
+ log("skipped", { why: "no model context, native or polyfilled" });
104
+ return;
105
+ }
106
+ // The import is async, so the component may already be gone.
107
+ if (controller.signal.aborted) return;
108
+
109
+ const report = await registerStudioTools(
110
+ modelContext,
111
+ buildStudioTools(depsRef),
112
+ controller.signal,
113
+ );
114
+ reportRegistration(report, native !== null);
115
+ })();
116
+
117
+ return () => controller.abort();
118
+ }, []);
119
+ }