@hyperframes/studio 0.8.19 → 0.8.21

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 (45) hide show
  1. package/dist/assets/hyperframes-player-CIXNA_bj.js +460 -0
  2. package/dist/assets/{index-Dd0fsIIL.js → index-44gfDvkh.js} +1 -1
  3. package/dist/assets/{index-DxccOrkZ.js → index-9VfE7bZe.js} +1 -1
  4. package/dist/assets/{index-Bjd2hioj.js → index-CgGY8dAv.js} +164 -164
  5. package/dist/index.d.ts +28 -0
  6. package/dist/index.html +1 -1
  7. package/dist/index.js +1206 -234
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/App.tsx +2 -0
  11. package/src/contexts/StudioContext.tsx +10 -0
  12. package/src/hooks/useEditorSave.test.tsx +81 -3
  13. package/src/hooks/useEditorSave.ts +31 -6
  14. package/src/hooks/useGsapInteractionFailureTelemetry.test.tsx +48 -16
  15. package/src/hooks/useGsapInteractionFailureTelemetry.ts +5 -2
  16. package/src/hooks/useStudioContextValue.ts +9 -0
  17. package/src/player/hooks/timelineSyncHydration.test.ts +96 -0
  18. package/src/player/hooks/timelineSyncHydration.ts +47 -1
  19. package/src/player/hooks/useExpandedTimelineElements.test.ts +43 -0
  20. package/src/player/hooks/useExpandedTimelineElements.ts +52 -29
  21. package/src/player/hooks/useTimelineSyncCallbacks.ts +4 -0
  22. package/src/player/store/playerStore.ts +17 -2
  23. package/src/player/store/timelineElement.ts +21 -0
  24. package/src/utils/studioFileVersion.test.ts +37 -1
  25. package/src/utils/studioFileVersion.ts +12 -2
  26. package/src/utils/studioSaveDiagnostics.test.ts +29 -0
  27. package/src/utils/studioSaveDiagnostics.ts +4 -0
  28. package/src/webmcp/StudioAgentTools.tsx +105 -10
  29. package/src/webmcp/toolResult.ts +1 -1
  30. package/src/webmcp/tools/animationTools.test.ts +244 -0
  31. package/src/webmcp/tools/animationTools.ts +276 -0
  32. package/src/webmcp/tools/contentTools.test.ts +275 -0
  33. package/src/webmcp/tools/contentTools.ts +217 -0
  34. package/src/webmcp/tools/frameTools.test.ts +136 -0
  35. package/src/webmcp/tools/frameTools.ts +133 -0
  36. package/src/webmcp/tools/inspectTools.test.ts +197 -0
  37. package/src/webmcp/tools/inspectTools.ts +208 -0
  38. package/src/webmcp/tools/selectionTools.test.ts +164 -0
  39. package/src/webmcp/tools/selectionTools.ts +154 -0
  40. package/src/webmcp/tools/transformTools.test.ts +179 -0
  41. package/src/webmcp/tools/transformTools.ts +205 -0
  42. package/src/webmcp/useStudioAgentTools.test.tsx +71 -22
  43. package/src/webmcp/useStudioAgentTools.ts +195 -1
  44. package/src/webmcp/webmcpTestUtils.ts +91 -0
  45. package/dist/assets/hyperframes-player-BA-QO9CQ.js +0 -459
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `studio_frame`: the eyes.
3
+ *
4
+ * Without this the tool set is a remote control. With it an agent can author a
5
+ * change, look at the instant it affects, judge it, and adjust. That loop is the
6
+ * one thing source alone cannot support, because "what does this look like at
7
+ * 2.4 seconds" is not a question a file can answer.
8
+ *
9
+ * Reuses Studio's existing capture endpoint (`utils/frameCapture`) rather than
10
+ * inventing a second one. The server renders the composition with Puppeteer, so
11
+ * the frame reflects the file on disk, not the live preview DOM.
12
+ */
13
+
14
+ import { buildFrameCaptureUrl } from "../../utils/frameCapture";
15
+ import { toolFailure, toolOk, type ToolResult } from "../toolResult";
16
+
17
+ export interface FrameToolDeps {
18
+ getProjectId: () => string | null;
19
+ getCompositionPath: () => string | null;
20
+ readPlayhead: () => { currentTime: number; duration: number; isPlaying: boolean };
21
+ requestSeek: (time: number) => void;
22
+ /** Confirms the URL renders. Injected so tests need no network. */
23
+ probeFrame: (url: string) => Promise<{ ok: boolean; status: number }>;
24
+ wait: (ms: number) => Promise<void>;
25
+ }
26
+
27
+ export interface StudioFrameResult {
28
+ /** Fetch this to see the frame. A PNG of the composition at `time`. */
29
+ url: string;
30
+ time: number;
31
+ compositionPath: string;
32
+ /** How long the tool waited for a pending write to settle before capturing. */
33
+ settledMs: number;
34
+ }
35
+
36
+ export interface StudioFrameInput {
37
+ /** Seconds. Omit to capture wherever the playhead already is. */
38
+ time?: number;
39
+ /**
40
+ * Milliseconds to wait before capturing, so a just-written edit is visible.
41
+ * See the staleness note in the description.
42
+ */
43
+ settleMs?: number;
44
+ }
45
+
46
+ /**
47
+ * Long enough to cover the project watcher's 40ms write-stability threshold
48
+ * plus filesystem latency, short enough not to be felt. This is the mitigation
49
+ * for a real, previously-fixed bug: the preview signature is invalidated by a
50
+ * file watcher, and a capture that beats the watcher renders the PRE-edit
51
+ * composition. An agent reading that as "my edit failed" would thrash.
52
+ */
53
+ const DEFAULT_SETTLE_MS = 150;
54
+ const MAX_SETTLE_MS = 5_000;
55
+
56
+ export async function studioFrame(
57
+ deps: FrameToolDeps,
58
+ input: StudioFrameInput = {},
59
+ ): Promise<ToolResult<StudioFrameResult>> {
60
+ const projectId = deps.getProjectId();
61
+ if (!projectId) {
62
+ return toolFailure("blocked", "no project is open");
63
+ }
64
+
65
+ if (input.time !== undefined) {
66
+ if (typeof input.time !== "number" || !Number.isFinite(input.time) || input.time < 0) {
67
+ return toolFailure("invalid", "time must be a non-negative, finite number of seconds");
68
+ }
69
+ deps.requestSeek(input.time);
70
+ }
71
+
72
+ const settledMs = clampSettle(input.settleMs);
73
+ if (settledMs > 0) await deps.wait(settledMs);
74
+
75
+ // Capture whatever the playhead now reads, rather than what was requested:
76
+ // the player clamps, so those can differ and the frame belongs to the former.
77
+ const { currentTime } = deps.readPlayhead();
78
+ const compositionPath = deps.getCompositionPath();
79
+ const url = buildFrameCaptureUrl({ projectId, compositionPath, currentTime });
80
+
81
+ const probe = await deps.probeFrame(url);
82
+ if (!probe.ok) {
83
+ return toolFailure(
84
+ "failed",
85
+ `the renderer returned ${probe.status} for this frame`,
86
+ "The composition may not build. Try `hyperframes check`.",
87
+ );
88
+ }
89
+
90
+ return toolOk<StudioFrameResult>({
91
+ url,
92
+ time: currentTime,
93
+ compositionPath: compositionPath ?? "index.html",
94
+ settledMs,
95
+ });
96
+ }
97
+
98
+ function clampSettle(requested: number | undefined): number {
99
+ if (requested === undefined) return DEFAULT_SETTLE_MS;
100
+ if (typeof requested !== "number" || !Number.isFinite(requested) || requested < 0) {
101
+ return DEFAULT_SETTLE_MS;
102
+ }
103
+ return Math.min(requested, MAX_SETTLE_MS);
104
+ }
105
+
106
+ export const STUDIO_FRAME_INPUT_SCHEMA = {
107
+ type: "object",
108
+ properties: {
109
+ time: {
110
+ type: "number",
111
+ minimum: 0,
112
+ description: "Seconds. Omit to capture wherever the playhead already is.",
113
+ },
114
+ settleMs: {
115
+ type: "integer",
116
+ minimum: 0,
117
+ maximum: MAX_SETTLE_MS,
118
+ description: `Wait this long before capturing so a just-made edit is included. Default ${DEFAULT_SETTLE_MS}.`,
119
+ },
120
+ },
121
+ additionalProperties: false,
122
+ } as const;
123
+
124
+ export const STUDIO_FRAME_DESCRIPTION = [
125
+ "Render the composition to a PNG at a given time and return its URL, so you can",
126
+ "SEE the result instead of inferring it from source. Use this to judge a change:",
127
+ "edit, capture the instant it affects, look, adjust.",
128
+ "The frame is rendered from the file on disk, not the live preview.",
129
+ "A capture taken immediately after an edit can therefore predate that edit, because",
130
+ "the render cache is cleared by a file watcher. The tool waits briefly to cover that;",
131
+ "raise `settleMs` if a frame still looks stale, rather than concluding the edit failed.",
132
+ "Returns `ok: true` with `url` and the `time` actually captured, or `ok: false`.",
133
+ ].join(" ");
@@ -0,0 +1,197 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
4
+ import { studioInspect, type InspectToolDeps, type StudioInspectResult } from "./inspectTools";
5
+ import {
6
+ expectFailure,
7
+ expectOk,
8
+ previewDoc,
9
+ previewElement,
10
+ selectionFor,
11
+ } from "../webmcpTestUtils";
12
+
13
+ function animation(overrides: Partial<GsapAnimation> = {}): GsapAnimation {
14
+ return {
15
+ id: "anim-1",
16
+ targetSelector: "#headline",
17
+ method: "from",
18
+ position: 0,
19
+ properties: { y: -50, opacity: 0 },
20
+ duration: 1,
21
+ ease: "power2.out",
22
+ ...overrides,
23
+ } as GsapAnimation;
24
+ }
25
+
26
+ function inspectDeps(overrides: Partial<InspectToolDeps> = {}): InspectToolDeps {
27
+ return {
28
+ getPreviewDocument: () => null,
29
+ buildSelection: async (element) => selectionFor(element),
30
+ applySelection: () => undefined,
31
+ requestSeek: () => undefined,
32
+ readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
33
+ getCurrentSelection: () => null,
34
+ getGsapDiagnostics: () => ({
35
+ animations: [],
36
+ multipleTimelines: false,
37
+ unsupportedTimelinePattern: false,
38
+ }),
39
+ ...overrides,
40
+ };
41
+ }
42
+
43
+ describe("studioInspect", () => {
44
+ it("returns the resolved styles, not the authored ones", async () => {
45
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
46
+ const selection = selectionFor(element);
47
+
48
+ const result = await studioInspect(inspectDeps({ getCurrentSelection: () => selection }));
49
+
50
+ const ok = expectOk<StudioInspectResult>(result);
51
+ // The authored value is a clamp(); the resolved one is what actually renders.
52
+ expect(ok.styles["font-size"]).toBe("42.7px");
53
+ expect(ok.inlineStyles.color).toBe("red");
54
+ expect(ok.box.width).toBe(880);
55
+ });
56
+
57
+ it("reports capabilities and the disabled reason verbatim", async () => {
58
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
59
+ const locked = selectionFor(element, {
60
+ capabilities: {
61
+ canSelect: true,
62
+ canEditStyles: false,
63
+ canCrop: false,
64
+ canMove: false,
65
+ canResize: false,
66
+ canApplyManualOffset: false,
67
+ canApplyManualSize: false,
68
+ canApplyManualRotation: false,
69
+ reasonIfDisabled: "Element is inside a locked composition",
70
+ },
71
+ });
72
+
73
+ const result = await studioInspect(inspectDeps({ getCurrentSelection: () => locked }));
74
+
75
+ const ok = expectOk<StudioInspectResult>(result);
76
+ expect(ok.can.editStyles).toBe(false);
77
+ expect(ok.can.move).toBe(false);
78
+ expect(ok.can.reasonIfDisabled).toBe("Element is inside a locked composition");
79
+ });
80
+
81
+ it("lists the animations on the current selection", async () => {
82
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
83
+
84
+ const result = await studioInspect(
85
+ inspectDeps({
86
+ getCurrentSelection: () => selectionFor(element),
87
+ getGsapDiagnostics: () => ({
88
+ animations: [animation()],
89
+ multipleTimelines: false,
90
+ unsupportedTimelinePattern: false,
91
+ }),
92
+ }),
93
+ );
94
+
95
+ const ok = expectOk<StudioInspectResult>(result);
96
+ expect(ok.animations).toHaveLength(1);
97
+ expect(ok.animations[0]?.animationId).toBe("anim-1");
98
+ expect(ok.animations[0]?.ease).toBe("power2.out");
99
+ expect(ok.animationEditingBlocked).toBeNull();
100
+ });
101
+
102
+ it("says WHY animation editing is unavailable, so a write is not attempted", async () => {
103
+ const element = previewElement('<h1 id="headline">Ship it</h1>', "headline");
104
+ const base = {
105
+ getCurrentSelection: () => selectionFor(element),
106
+ };
107
+
108
+ const multiple = await studioInspect(
109
+ inspectDeps({
110
+ ...base,
111
+ getGsapDiagnostics: () => ({
112
+ animations: [],
113
+ multipleTimelines: true,
114
+ unsupportedTimelinePattern: false,
115
+ }),
116
+ }),
117
+ );
118
+ const unsupported = await studioInspect(
119
+ inspectDeps({
120
+ ...base,
121
+ getGsapDiagnostics: () => ({
122
+ animations: [],
123
+ multipleTimelines: false,
124
+ unsupportedTimelinePattern: true,
125
+ }),
126
+ }),
127
+ );
128
+
129
+ expect(expectOk<StudioInspectResult>(multiple).animationEditingBlocked).toMatch(
130
+ /multiple GSAP timelines/,
131
+ );
132
+ expect(expectOk<StudioInspectResult>(unsupported).animationEditingBlocked).toMatch(
133
+ /not editable/,
134
+ );
135
+ });
136
+
137
+ it("does not attribute the selection's animations to a different element", async () => {
138
+ // Studio only parses animations for the CURRENT selection. Reporting them
139
+ // against another element would report the wrong element's motion.
140
+ const headline = previewElement('<h1 id="headline">A</h1><p id="body">B</p>', "headline");
141
+ const doc = headline.ownerDocument;
142
+
143
+ const result = await studioInspect(
144
+ inspectDeps({
145
+ getPreviewDocument: () => doc,
146
+ getCurrentSelection: () => selectionFor(headline),
147
+ getGsapDiagnostics: () => ({
148
+ animations: [animation()],
149
+ multipleTimelines: false,
150
+ unsupportedTimelinePattern: false,
151
+ }),
152
+ }),
153
+ { handle: "dom:body" },
154
+ );
155
+
156
+ const ok = expectOk<StudioInspectResult>(result);
157
+ expect(ok.isCurrentSelection).toBe(false);
158
+ expect(ok.animations).toEqual([]);
159
+ expect(ok.animationEditingBlocked).toMatch(/only readable for the current selection/);
160
+ });
161
+
162
+ it("inspects a handle without changing what is selected", async () => {
163
+ const doc = previewDoc('<h1 id="headline">A</h1>');
164
+ const applySelection = vi.fn();
165
+
166
+ const result = await studioInspect(
167
+ inspectDeps({ getPreviewDocument: () => doc, applySelection }),
168
+ { handle: "dom:headline" },
169
+ );
170
+
171
+ expect(result.ok).toBe(true);
172
+ // Inspecting is a read. It must not steal the human's selection.
173
+ expect(applySelection).not.toHaveBeenCalled();
174
+ });
175
+
176
+ it("fails rather than returning an empty result when nothing is selected", async () => {
177
+ const result = expectFailure(await studioInspect(inspectDeps()));
178
+
179
+ // An empty result would assert "this element has nothing", a different and
180
+ // false claim from "you did not say which element".
181
+ expect(result.kind).toBe("invalid");
182
+ expect(result.reason).toMatch(/nothing is selected/);
183
+ expect(result.hint).toMatch(/studio_select/);
184
+ });
185
+
186
+ it("reports an unknown handle distinctly from an unmounted preview", async () => {
187
+ const notMounted = expectFailure(await studioInspect(inspectDeps(), { handle: "dom:x" }));
188
+ expect(notMounted.kind).toBe("blocked");
189
+
190
+ const doc = previewDoc('<h1 id="headline">A</h1>');
191
+ const unknown = expectFailure(
192
+ await studioInspect(inspectDeps({ getPreviewDocument: () => doc }), { handle: "dom:x" }),
193
+ );
194
+ expect(unknown.kind).toBe("invalid");
195
+ expect(unknown.reason).not.toBe(notMounted.reason);
196
+ });
197
+ });
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `studio_inspect`: everything about one element, in one call.
3
+ *
4
+ * The point is to prevent a failed write. Every field here either tells the
5
+ * agent what it can change (`can`, with `reasonIfDisabled` verbatim) or what it
6
+ * would be changing (the resolved styles, the text fields, the animations).
7
+ * An agent that reads this first should never attempt an edit the element will
8
+ * refuse.
9
+ *
10
+ * The GSAP diagnostics are here for the same reason: `multipleTimelines` and
11
+ * `unsupportedTimelinePattern` are states where animation editing is off, and
12
+ * learning that from a read is cheaper than learning it from a failed write.
13
+ */
14
+
15
+ import type { GsapAnimation } from "@hyperframes/parsers/gsap-parser";
16
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
17
+ import { mintElementHandle, patchTargetAddress, resolveElementHandle } from "../handles";
18
+ import { toolFailure, toolOk, type ToolResult } from "../toolResult";
19
+ import type { SelectionToolDeps } from "./selectionTools";
20
+
21
+ export interface InspectToolDeps extends SelectionToolDeps {
22
+ /** What the human currently has selected, used when no handle is given. */
23
+ getCurrentSelection: () => DomEditSelection | null;
24
+ getGsapDiagnostics: () => {
25
+ animations: readonly GsapAnimation[];
26
+ multipleTimelines: boolean;
27
+ unsupportedTimelinePattern: boolean;
28
+ };
29
+ }
30
+
31
+ interface InspectAnimation {
32
+ animationId: string;
33
+ method: string;
34
+ target: string;
35
+ position: number | string;
36
+ duration: number | null;
37
+ ease: string | null;
38
+ properties: Record<string, number | string>;
39
+ hasKeyframes: boolean;
40
+ hasArcPath: boolean;
41
+ }
42
+
43
+ interface InspectTextField {
44
+ key: string;
45
+ label: string;
46
+ value: string;
47
+ tagName: string;
48
+ }
49
+
50
+ export interface StudioInspectResult {
51
+ handle: string | null;
52
+ label: string;
53
+ tagName: string;
54
+ sourceFile: string;
55
+ box: { x: number; y: number; width: number; height: number };
56
+ text: string | null;
57
+ textFields: InspectTextField[];
58
+ /** The styles Studio itself surfaces, resolved, not as authored. */
59
+ styles: Record<string, string>;
60
+ inlineStyles: Record<string, string>;
61
+ dataAttributes: Record<string, string>;
62
+ can: {
63
+ editStyles: boolean;
64
+ move: boolean;
65
+ resize: boolean;
66
+ rotate: boolean;
67
+ crop: boolean;
68
+ editText: boolean;
69
+ reasonIfDisabled: string | null;
70
+ };
71
+ animations: InspectAnimation[];
72
+ /** Present only when animation editing is unavailable, with the reason. */
73
+ animationEditingBlocked: string | null;
74
+ /** True when this element is the one the human currently has selected. */
75
+ isCurrentSelection: boolean;
76
+ }
77
+
78
+ export interface StudioInspectInput {
79
+ /** Omit to inspect the current selection. */
80
+ handle?: string;
81
+ }
82
+
83
+ function describeAnimation(animation: GsapAnimation): InspectAnimation {
84
+ return {
85
+ animationId: animation.id,
86
+ method: animation.method,
87
+ target: animation.targetSelector,
88
+ position: animation.position,
89
+ duration: animation.duration ?? null,
90
+ ease: animation.ease ?? null,
91
+ properties: animation.properties,
92
+ hasKeyframes: animation.keyframes !== undefined,
93
+ hasArcPath: animation.arcPath !== undefined,
94
+ };
95
+ }
96
+
97
+ function describe(
98
+ selection: DomEditSelection,
99
+ deps: InspectToolDeps,
100
+ isCurrentSelection: boolean,
101
+ ): ToolResult<StudioInspectResult> {
102
+ const { capabilities } = selection;
103
+ const gsap = deps.getGsapDiagnostics();
104
+
105
+ // Only the CURRENT selection's animations are parsed by Studio. Reporting
106
+ // them for some other element would be reporting the wrong element's motion,
107
+ // which is worse than reporting none.
108
+ const animations = isCurrentSelection ? gsap.animations.map(describeAnimation) : [];
109
+
110
+ let animationEditingBlocked: string | null = null;
111
+ if (!isCurrentSelection) {
112
+ animationEditingBlocked = "animations are only readable for the current selection";
113
+ } else if (gsap.multipleTimelines) {
114
+ animationEditingBlocked = "this composition has multiple GSAP timelines";
115
+ } else if (gsap.unsupportedTimelinePattern) {
116
+ animationEditingBlocked = "this composition's timeline pattern is not editable by Studio";
117
+ }
118
+
119
+ return toolOk<StudioInspectResult>({
120
+ handle: mintElementHandle(patchTargetAddress(selection)),
121
+ label: selection.label,
122
+ tagName: selection.tagName,
123
+ sourceFile: selection.sourceFile,
124
+ box: selection.boundingBox,
125
+ text: selection.textContent,
126
+ textFields: selection.textFields.map((field) => ({
127
+ key: field.key,
128
+ label: field.label,
129
+ value: field.value,
130
+ tagName: field.tagName,
131
+ })),
132
+ styles: selection.computedStyles,
133
+ inlineStyles: selection.inlineStyles,
134
+ dataAttributes: selection.dataAttributes,
135
+ can: {
136
+ editStyles: capabilities.canEditStyles,
137
+ move: capabilities.canMove || capabilities.canApplyManualOffset,
138
+ resize: capabilities.canResize || capabilities.canApplyManualSize,
139
+ rotate: capabilities.canApplyManualRotation,
140
+ crop: capabilities.canCrop,
141
+ editText: selection.textFields.length > 0,
142
+ reasonIfDisabled: capabilities.reasonIfDisabled ?? null,
143
+ },
144
+ animations,
145
+ animationEditingBlocked,
146
+ isCurrentSelection,
147
+ });
148
+ }
149
+
150
+ export async function studioInspect(
151
+ deps: InspectToolDeps,
152
+ input: StudioInspectInput = {},
153
+ ): Promise<ToolResult<StudioInspectResult>> {
154
+ const current = deps.getCurrentSelection();
155
+
156
+ if (!input.handle) {
157
+ // An empty result here would assert "this element has nothing", which is a
158
+ // different and false claim from "you did not tell me which element".
159
+ if (!current) {
160
+ return toolFailure(
161
+ "invalid",
162
+ "nothing is selected and no handle was given",
163
+ "Pass a handle from studio_look, or call studio_select first.",
164
+ );
165
+ }
166
+ return describe(current, deps, true);
167
+ }
168
+
169
+ const doc = deps.getPreviewDocument();
170
+ if (!doc) return toolFailure("blocked", "the preview is not mounted yet");
171
+
172
+ const element = resolveElementHandle(doc, input.handle);
173
+ if (!element) {
174
+ return toolFailure(
175
+ "invalid",
176
+ `no element matches handle ${input.handle}`,
177
+ "Call studio_look for current handles.",
178
+ );
179
+ }
180
+
181
+ const selection = await deps.buildSelection(element);
182
+ if (!selection) {
183
+ return toolFailure("blocked", `${input.handle} resolved to an element Studio cannot inspect`);
184
+ }
185
+
186
+ return describe(selection, deps, current?.element === element);
187
+ }
188
+
189
+ export const STUDIO_INSPECT_INPUT_SCHEMA = {
190
+ type: "object",
191
+ properties: {
192
+ handle: {
193
+ type: "string",
194
+ description: "An element handle from studio_look. Omit to inspect the current selection.",
195
+ },
196
+ },
197
+ additionalProperties: false,
198
+ } as const;
199
+
200
+ export const STUDIO_INSPECT_DESCRIPTION = [
201
+ "Everything about one element: its resolved styles, its text fields, its box,",
202
+ "its GSAP animations, and crucially what it will and will not accept.",
203
+ "Read this BEFORE editing. `can` tells you which edits are possible and",
204
+ "`can.reasonIfDisabled` says why one is not, so you can avoid a write that would be refused.",
205
+ "Animations are only readable for the CURRENT selection; `animationEditingBlocked` says when",
206
+ "and why animation editing is unavailable.",
207
+ "Returns `ok: true`, or `ok: false` with `kind`, `reason` and a `hint`.",
208
+ ].join(" ");
@@ -0,0 +1,164 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ studioSeek,
5
+ studioSelect,
6
+ type SelectionToolDeps,
7
+ type StudioSeekResult,
8
+ type StudioSelectResult,
9
+ } from "./selectionTools";
10
+ import { expectFailure, expectOk, previewDoc, selectionFor } from "../webmcpTestUtils";
11
+
12
+ function selectionDeps(overrides: Partial<SelectionToolDeps> = {}): SelectionToolDeps {
13
+ return {
14
+ getPreviewDocument: () => null,
15
+ buildSelection: async (element) => selectionFor(element),
16
+ applySelection: () => undefined,
17
+ requestSeek: () => undefined,
18
+ readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }),
19
+ ...overrides,
20
+ };
21
+ }
22
+
23
+ describe("studioSelect", () => {
24
+ it("applies the selection a click would produce and reports it back", async () => {
25
+ const doc = previewDoc('<h1 id="headline" data-hf-id="abc">Ship it</h1>');
26
+ const applySelection = vi.fn();
27
+
28
+ const result = await studioSelect(
29
+ selectionDeps({ getPreviewDocument: () => doc, applySelection }),
30
+ "hf:abc",
31
+ );
32
+
33
+ const ok = expectOk<StudioSelectResult>(result);
34
+ expect(ok.handle).toBe("hf:abc");
35
+ expect(ok.label).toBe("Headline");
36
+ expect(ok.box.width).toBe(880);
37
+ // Reveals the inspector, which is what makes the human see what the agent did.
38
+ expect(applySelection).toHaveBeenCalledTimes(1);
39
+ });
40
+
41
+ it("distinguishes a preview that is not mounted from a handle that does not match", async () => {
42
+ const notMounted = expectFailure(await studioSelect(selectionDeps(), "dom:headline"));
43
+ expect(notMounted.kind).toBe("blocked");
44
+ expect(notMounted.reason).toMatch(/not mounted/);
45
+
46
+ const doc = previewDoc('<h1 id="headline">Ship it</h1>');
47
+ const noMatch = expectFailure(
48
+ await studioSelect(selectionDeps({ getPreviewDocument: () => doc }), "dom:missing"),
49
+ );
50
+ expect(noMatch.kind).toBe("invalid");
51
+ expect(noMatch.reason).toMatch(/no element matches/);
52
+ // The two must not be the same message: waiting and re-reading are different fixes.
53
+ expect(noMatch.reason).not.toBe(notMounted.reason);
54
+ });
55
+
56
+ it("reports an element Studio cannot build a selection for, as a third case", async () => {
57
+ const doc = previewDoc('<h1 id="headline">Ship it</h1>');
58
+
59
+ const result = expectFailure(
60
+ await studioSelect(
61
+ selectionDeps({ getPreviewDocument: () => doc, buildSelection: async () => null }),
62
+ "dom:headline",
63
+ ),
64
+ );
65
+
66
+ expect(result.kind).toBe("blocked");
67
+ expect(result.reason).toMatch(/cannot select/);
68
+ });
69
+
70
+ it("rejects a missing handle without touching the preview", async () => {
71
+ const getPreviewDocument = vi.fn(() => null);
72
+
73
+ const result = expectFailure(await studioSelect(selectionDeps({ getPreviewDocument }), " "));
74
+
75
+ expect(result.kind).toBe("invalid");
76
+ expect(getPreviewDocument).not.toHaveBeenCalled();
77
+ });
78
+
79
+ it("leaves the existing selection alone when it fails", async () => {
80
+ const doc = previewDoc('<h1 id="headline">Ship it</h1>');
81
+ const applySelection = vi.fn();
82
+
83
+ await studioSelect(
84
+ selectionDeps({ getPreviewDocument: () => doc, applySelection }),
85
+ "dom:missing",
86
+ );
87
+
88
+ expect(applySelection).not.toHaveBeenCalled();
89
+ });
90
+ });
91
+
92
+ describe("studioSeek", () => {
93
+ it("reports where the playhead landed, not what was requested", () => {
94
+ // The player clamps against the ADAPTER's duration, which the wrapper
95
+ // deliberately does not second-guess.
96
+ let currentTime = 0;
97
+ const result = studioSeek(
98
+ selectionDeps({
99
+ requestSeek: () => {
100
+ currentTime = 10;
101
+ },
102
+ readPlayhead: () => ({ currentTime, duration: 10, isPlaying: false }),
103
+ }),
104
+ 999,
105
+ );
106
+
107
+ const ok = expectOk<StudioSeekResult>(result);
108
+ expect(ok.playhead).toBe(10);
109
+ expect(ok.moved).toBe(true);
110
+ });
111
+
112
+ it("reports that playback stopped", () => {
113
+ let isPlaying = true;
114
+ let currentTime = 0;
115
+ const result = studioSeek(
116
+ selectionDeps({
117
+ requestSeek: () => {
118
+ currentTime = 2;
119
+ isPlaying = false;
120
+ },
121
+ readPlayhead: () => ({ currentTime, duration: 10, isPlaying }),
122
+ }),
123
+ 2,
124
+ );
125
+
126
+ expect(expectOk<StudioSeekResult>(result).isPlaying).toBe(false);
127
+ });
128
+
129
+ it("fails rather than claiming a seek the player never received", () => {
130
+ // `requestSeek` is fire-and-forget: with no adapter mounted it silently does
131
+ // nothing, and reporting ok would be a lie the agent builds on.
132
+ const result = expectFailure(
133
+ studioSeek(
134
+ selectionDeps({ readPlayhead: () => ({ currentTime: 0, duration: 10, isPlaying: false }) }),
135
+ 5,
136
+ ),
137
+ );
138
+
139
+ expect(result.kind).toBe("blocked");
140
+ expect(result.reason).toMatch(/did not move/);
141
+ });
142
+
143
+ it("succeeds when asked to seek to where the playhead already is", () => {
144
+ const result = studioSeek(
145
+ selectionDeps({ readPlayhead: () => ({ currentTime: 3, duration: 10, isPlaying: false }) }),
146
+ 3,
147
+ );
148
+
149
+ // Nothing moved, but nothing failed either, and `moved` says which.
150
+ const ok = expectOk<StudioSeekResult>(result);
151
+ expect(ok.moved).toBe(false);
152
+ expect(ok.playhead).toBe(3);
153
+ });
154
+
155
+ it("rejects a non-finite time without calling the player", () => {
156
+ const requestSeek = vi.fn();
157
+
158
+ for (const time of [Number.NaN, Number.POSITIVE_INFINITY]) {
159
+ const result = expectFailure(studioSeek(selectionDeps({ requestSeek }), time));
160
+ expect(result.kind).toBe("invalid");
161
+ }
162
+ expect(requestSeek).not.toHaveBeenCalled();
163
+ });
164
+ });