@hyperframes/studio 0.8.16 → 0.8.18

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 (55) hide show
  1. package/dist/assets/{hyperframes-player-iAIHATIw.js → hyperframes-player-DzRNJZAz.js} +1 -1
  2. package/dist/assets/{index-Cf-mbMRL.js → index-B4qse6wy.js} +1 -1
  3. package/dist/assets/index-BX3KHhGX.js +71 -0
  4. package/dist/assets/{index-D8o3ZIo2.js → index-F-PUkOVc.js} +128 -128
  5. package/dist/assets/{index-YmetcS6L.js → index-SGl0bb71.js} +1 -1
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.html +1 -1
  8. package/dist/index.js +1640 -1282
  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/components/nle/useCompositionStack.test.tsx +69 -0
  30. package/src/components/nle/useCompositionStack.ts +8 -2
  31. package/src/hooks/domEditCommitRunner.ts +47 -0
  32. package/src/hooks/useDomEditPositionPatchCommit.test.tsx +116 -0
  33. package/src/hooks/useDomEditPositionPatchCommit.ts +6 -1
  34. package/src/hooks/useDomEditTextCommits.test.tsx +175 -21
  35. package/src/hooks/useDomEditTextCommits.ts +17 -9
  36. package/src/hooks/useDomEditWiring.ts +1 -1
  37. package/src/hooks/useDomGeometryCommits.test.tsx +1 -0
  38. package/src/hooks/useDomGeometryCommits.ts +10 -2
  39. package/src/hooks/useElementLifecycleOps.multiDelete.test.tsx +81 -31
  40. package/src/hooks/useElementLifecycleOps.ts +11 -5
  41. package/src/hooks/useGsapSelectionHandlers.ts +4 -2
  42. package/src/utils/studioUiPreferences.ts +11 -0
  43. package/src/webmcp/StudioAgentTools.tsx +46 -0
  44. package/src/webmcp/handles.test.ts +130 -0
  45. package/src/webmcp/handles.ts +129 -0
  46. package/src/webmcp/polyfill.test.ts +98 -0
  47. package/src/webmcp/polyfill.ts +60 -0
  48. package/src/webmcp/registrar.test.ts +150 -0
  49. package/src/webmcp/registrar.ts +115 -0
  50. package/src/webmcp/toolResult.ts +67 -0
  51. package/src/webmcp/tools/lookTools.test.ts +225 -0
  52. package/src/webmcp/tools/lookTools.ts +201 -0
  53. package/src/webmcp/types.ts +90 -0
  54. package/src/webmcp/useStudioAgentTools.test.tsx +221 -0
  55. package/src/webmcp/useStudioAgentTools.ts +119 -0
@@ -0,0 +1,150 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { findToolDefinitionError, registerStudioTools } from "./registrar";
4
+ import type { ModelContext, ModelContextTool } from "./types";
5
+
6
+ function tool(overrides: Partial<ModelContextTool> = {}): ModelContextTool {
7
+ return {
8
+ name: "studio_look",
9
+ description: "Read Studio's live state.",
10
+ execute: async () => ({ ok: true }),
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ function fakeModelContext(
16
+ registerTool: ModelContext["registerTool"] = vi.fn().mockResolvedValue(undefined),
17
+ ): ModelContext {
18
+ return { registerTool };
19
+ }
20
+
21
+ function domException(name: string, message = name): DOMException {
22
+ return new DOMException(message, name);
23
+ }
24
+
25
+ describe("findToolDefinitionError", () => {
26
+ it("accepts the names Studio actually uses", () => {
27
+ expect(findToolDefinitionError(tool({ name: "studio_look" }))).toBeNull();
28
+ expect(findToolDefinitionError(tool({ name: "studio.look-2" }))).toBeNull();
29
+ });
30
+
31
+ it("rejects a name the browser would reject, naming the tool", () => {
32
+ expect(findToolDefinitionError(tool({ name: "studio look" }))).toMatch(/name must be/);
33
+ expect(findToolDefinitionError(tool({ name: "a".repeat(129) }))).toMatch(/name must be/);
34
+ expect(findToolDefinitionError(tool({ name: "" }))).toMatch(/name must be/);
35
+ });
36
+
37
+ it("rejects an empty description", () => {
38
+ expect(findToolDefinitionError(tool({ description: " " }))).toBe(
39
+ "description must not be empty",
40
+ );
41
+ });
42
+ });
43
+
44
+ describe("registerStudioTools", () => {
45
+ it("registers every tool with the shared abort signal", async () => {
46
+ const registerTool = vi.fn().mockResolvedValue(undefined);
47
+ const controller = new AbortController();
48
+
49
+ const report = await registerStudioTools(
50
+ fakeModelContext(registerTool),
51
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
52
+ controller.signal,
53
+ );
54
+
55
+ expect(report.registered).toEqual(["studio_look", "studio_frame"]);
56
+ expect(report.failed).toEqual([]);
57
+ expect(registerTool).toHaveBeenCalledTimes(2);
58
+ expect(registerTool.mock.calls[0]?.[1]).toEqual({ signal: controller.signal });
59
+ });
60
+
61
+ it("stops silently when the signal aborts mid-registration", async () => {
62
+ // A StrictMode mount-cleanup-mount rejects the in-flight registrations with
63
+ // AbortError. That is teardown working; it must not surface as a failure or
64
+ // escape as an unhandled rejection.
65
+ const registerTool = vi
66
+ .fn()
67
+ .mockResolvedValueOnce(undefined)
68
+ .mockRejectedValueOnce(domException("AbortError"));
69
+
70
+ const report = await registerStudioTools(
71
+ fakeModelContext(registerTool),
72
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
73
+ new AbortController().signal,
74
+ );
75
+
76
+ expect(report.registered).toEqual(["studio_look"]);
77
+ expect(report.failed).toEqual([]);
78
+ });
79
+
80
+ it("keeps the DOMException name, which is the only thing that tells the gates apart", async () => {
81
+ const registerTool = vi
82
+ .fn()
83
+ .mockRejectedValue(domException("SecurityError", "not origin-keyed"));
84
+
85
+ const report = await registerStudioTools(
86
+ fakeModelContext(registerTool),
87
+ [tool()],
88
+ new AbortController().signal,
89
+ );
90
+
91
+ expect(report.failed).toHaveLength(1);
92
+ expect(report.failed[0]?.tool).toBe("studio_look");
93
+ expect(report.failed[0]?.name).toBe("SecurityError");
94
+ // Substring, not equality: jsdom prefixes DOMException.message with the
95
+ // name and real browsers do not.
96
+ expect(report.failed[0]?.message).toContain("not origin-keyed");
97
+ expect(report.registered).toEqual([]);
98
+ });
99
+
100
+ it("keeps going after one tool fails", async () => {
101
+ const registerTool = vi
102
+ .fn()
103
+ .mockRejectedValueOnce(domException("NotAllowedError", "tools policy"))
104
+ .mockResolvedValueOnce(undefined);
105
+
106
+ const report = await registerStudioTools(
107
+ fakeModelContext(registerTool),
108
+ [tool({ name: "studio_look" }), tool({ name: "studio_frame" })],
109
+ new AbortController().signal,
110
+ );
111
+
112
+ expect(report.registered).toEqual(["studio_frame"]);
113
+ expect(report.failed.map((f) => f.tool)).toEqual(["studio_look"]);
114
+ });
115
+
116
+ it("catches a duplicate name before the browser does, so the report names it", async () => {
117
+ const registerTool = vi.fn().mockResolvedValue(undefined);
118
+
119
+ const report = await registerStudioTools(
120
+ fakeModelContext(registerTool),
121
+ [tool({ name: "studio_look" }), tool({ name: "studio_look" })],
122
+ new AbortController().signal,
123
+ );
124
+
125
+ expect(report.registered).toEqual(["studio_look"]);
126
+ expect(report.failed).toEqual([
127
+ {
128
+ tool: "studio_look",
129
+ name: "InvalidStateError",
130
+ message: "duplicate tool name in this registration set",
131
+ },
132
+ ]);
133
+ // Registering the same name twice REJECTS rather than replacing, so the
134
+ // second one must never reach the browser.
135
+ expect(registerTool).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("does not send a tool the browser would reject", async () => {
139
+ const registerTool = vi.fn().mockResolvedValue(undefined);
140
+
141
+ const report = await registerStudioTools(
142
+ fakeModelContext(registerTool),
143
+ [tool({ name: "studio look" })],
144
+ new AbortController().signal,
145
+ );
146
+
147
+ expect(registerTool).not.toHaveBeenCalled();
148
+ expect(report.failed[0]?.name).toBe("InvalidStateError");
149
+ });
150
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Registers Studio's tools with the browser, once.
3
+ *
4
+ * The only file besides `types.ts` that touches the WebMCP API, so a spec
5
+ * change lands here. Tool names are document-scoped, so Studio relies on its
6
+ * single live `EditorShell` mounting one `StudioAgentTools`. A second live
7
+ * shell would register the same names and receive `InvalidStateError`; the
8
+ * duplicate check below only owns duplicates within one registration set.
9
+ */
10
+
11
+ import type { ModelContext, ModelContextTool } from "./types";
12
+
13
+ export interface ToolRegistrationFailure {
14
+ tool: string;
15
+ /** The DOMException name where there is one. It is the only thing that tells
16
+ * a duplicate name (InvalidStateError) apart from a document that is not
17
+ * origin-keyed (SecurityError) or not permitted to use `tools`
18
+ * (NotAllowedError), and all three look identical without it. */
19
+ name: string;
20
+ message: string;
21
+ }
22
+
23
+ export interface ToolRegistrationReport {
24
+ registered: string[];
25
+ failed: ToolRegistrationFailure[];
26
+ }
27
+
28
+ /** Max 128 chars, ASCII alphanumeric plus `_`, `-`, `.` (`index.bs`). */
29
+ const VALID_TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
30
+
31
+ /**
32
+ * A tool whose name or description the browser would reject anyway. Caught here
33
+ * so the failure names the offending tool instead of arriving as one of N
34
+ * identical InvalidStateErrors.
35
+ */
36
+ export function findToolDefinitionError(tool: ModelContextTool): string | null {
37
+ if (!VALID_TOOL_NAME.test(tool.name)) {
38
+ return `name must be 1-128 chars of A-Z a-z 0-9 _ - . (got ${JSON.stringify(tool.name)})`;
39
+ }
40
+ if (!tool.description.trim()) return "description must not be empty";
41
+ return null;
42
+ }
43
+
44
+ function isAbortError(error: unknown): boolean {
45
+ return error instanceof DOMException && error.name === "AbortError";
46
+ }
47
+
48
+ function invalidState(tool: string, message: string): ToolRegistrationFailure {
49
+ return { tool, name: "InvalidStateError", message };
50
+ }
51
+
52
+ /**
53
+ * `"aborted"` is a third outcome, not a failure: teardown got there first and
54
+ * the caller should stop rather than record anything.
55
+ */
56
+ type RegisterOneOutcome =
57
+ | { status: "registered" }
58
+ | { status: "failed"; failure: ToolRegistrationFailure }
59
+ | { status: "aborted" };
60
+
61
+ async function registerOne(
62
+ modelContext: ModelContext,
63
+ tool: ModelContextTool,
64
+ signal: AbortSignal,
65
+ ): Promise<RegisterOneOutcome> {
66
+ const definitionError = findToolDefinitionError(tool);
67
+ if (definitionError) {
68
+ return { status: "failed", failure: invalidState(tool.name, definitionError) };
69
+ }
70
+
71
+ try {
72
+ await modelContext.registerTool(tool, { signal });
73
+ return { status: "registered" };
74
+ } catch (error) {
75
+ // A mount-cleanup-mount cycle (React StrictMode in dev) aborts the signal in
76
+ // the same task the registration promise is queued in, which rejects every
77
+ // registerTool with AbortError. That is teardown working, not a failure, and
78
+ // letting it escape fills the dev console with unhandled rejections.
79
+ if (isAbortError(error)) return { status: "aborted" };
80
+ return {
81
+ status: "failed",
82
+ failure: {
83
+ tool: tool.name,
84
+ name: error instanceof DOMException ? error.name : "Error",
85
+ message: error instanceof Error ? error.message : String(error),
86
+ },
87
+ };
88
+ }
89
+ }
90
+
91
+ export async function registerStudioTools(
92
+ modelContext: ModelContext,
93
+ tools: readonly ModelContextTool[],
94
+ signal: AbortSignal,
95
+ ): Promise<ToolRegistrationReport> {
96
+ const report: ToolRegistrationReport = { registered: [], failed: [] };
97
+ const seen = new Set<string>();
98
+
99
+ for (const tool of tools) {
100
+ // Registering a name twice REJECTS rather than replacing, so a duplicate
101
+ // must never reach the browser.
102
+ if (seen.has(tool.name)) {
103
+ report.failed.push(invalidState(tool.name, "duplicate tool name in this registration set"));
104
+ continue;
105
+ }
106
+ seen.add(tool.name);
107
+
108
+ const outcome = await registerOne(modelContext, tool, signal);
109
+ if (outcome.status === "aborted") return report;
110
+ if (outcome.status === "failed") report.failed.push(outcome.failure);
111
+ else report.registered.push(tool.name);
112
+ }
113
+
114
+ return report;
115
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The shape every Studio tool resolves with.
3
+ *
4
+ * Tools resolve, they never reject. That is forced by the spec, not a style
5
+ * choice: a rejected `execute` has its reason DISCARDED and the caller is
6
+ * rejected with a bare `UnknownError` (`index.bs`, the execute-tool completion
7
+ * steps). Rejecting would therefore guarantee the agent cannot see why the edit
8
+ * failed, which is the one thing it needs most.
9
+ *
10
+ * There is no `outputSchema` in the platform yet, so this discriminant is
11
+ * invisible to the agent's schema layer. Every tool's `description` has to say
12
+ * that it returns `ok`.
13
+ */
14
+
15
+ export type ToolFailureKind =
16
+ /** A real state the agent can route around: save queue paused, capability off. */
17
+ | "blocked"
18
+ /** The agent's fault: unknown handle, bad enum, out of range. */
19
+ | "invalid"
20
+ /** Exogenous: the server said no, the patch target could not be resolved. */
21
+ | "failed"
22
+ /** Our bug. Reported AND re-thrown, so it is findable instead of plausible. */
23
+ | "internal";
24
+
25
+ export interface ToolFailure {
26
+ ok: false;
27
+ kind: ToolFailureKind;
28
+ reason: string;
29
+ /** What to try instead. This is what turns a failure into a next action. */
30
+ hint?: string;
31
+ }
32
+
33
+ export type ToolResult<T> = ({ ok: true } & T) | ToolFailure;
34
+
35
+ export function toolOk<T extends object>(value: T): { ok: true } & T {
36
+ return { ok: true, ...value };
37
+ }
38
+
39
+ function toolFailure(kind: ToolFailureKind, reason: string, hint?: string): ToolFailure {
40
+ return hint ? { ok: false, kind, reason, hint } : { ok: false, kind, reason };
41
+ }
42
+
43
+ /**
44
+ * Wrap a tool body so a thrown bug becomes a legible result instead of a
45
+ * rejection the agent cannot read.
46
+ *
47
+ * The split matters. A `TypeError` means a handler signature moved under us and
48
+ * the tool is permanently broken; reporting that as an ordinary failure would
49
+ * let it ship looking like a bad request forever. So it is tagged `internal`
50
+ * AND re-thrown to the console, where it is findable. Everything else is a
51
+ * failure the agent should route around.
52
+ */
53
+ export async function runToolBody<T>(
54
+ toolName: string,
55
+ body: () => Promise<ToolResult<T>>,
56
+ ): Promise<ToolResult<T>> {
57
+ try {
58
+ return await body();
59
+ } catch (error) {
60
+ const reason = error instanceof Error ? error.message : String(error);
61
+ if (error instanceof TypeError || error instanceof ReferenceError) {
62
+ console.error(`[hf-webmcp] ${toolName} threw`, error);
63
+ return toolFailure("internal", reason);
64
+ }
65
+ return toolFailure("failed", reason);
66
+ }
67
+ }
@@ -0,0 +1,225 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, expect, it } from "vitest";
3
+ import type { DomEditSelection } from "../../components/editor/domEditingTypes";
4
+ import type { TimelineElement } from "../../player/store/timelineElement";
5
+ import { buildStudioLook, STUDIO_LOOK_INPUT_SCHEMA, type StudioLookSnapshot } from "./lookTools";
6
+
7
+ function element(overrides: Partial<TimelineElement>): TimelineElement {
8
+ return { id: "synthetic", tag: "div", start: 0, duration: 1, track: 0, ...overrides };
9
+ }
10
+
11
+ function snapshot(overrides: Partial<StudioLookSnapshot> = {}): StudioLookSnapshot {
12
+ return {
13
+ projectId: "demo",
14
+ compositionPath: "index.html",
15
+ currentTime: 1.5,
16
+ duration: 10,
17
+ isPlaying: false,
18
+ elements: [],
19
+ selection: null,
20
+ selectionAnimationCount: 0,
21
+ history: { canUndo: true, canRedo: false, undoLabel: "Move layer", redoLabel: null },
22
+ ...overrides,
23
+ };
24
+ }
25
+
26
+ function selection(overrides: Partial<DomEditSelection> = {}): DomEditSelection {
27
+ return {
28
+ id: "headline",
29
+ hfId: "abc123",
30
+ element: document.createElement("div"),
31
+ label: "Headline",
32
+ tagName: "h1",
33
+ sourceFile: "index.html",
34
+ compositionPath: "index.html",
35
+ isCompositionHost: false,
36
+ isInsideLockedComposition: false,
37
+ boundingBox: { x: 40, y: 12, width: 880, height: 96 },
38
+ textContent: "Ship it",
39
+ dataAttributes: {},
40
+ inlineStyles: {},
41
+ computedStyles: {},
42
+ textFields: [],
43
+ capabilities: {
44
+ canSelect: true,
45
+ canEditStyles: true,
46
+ canCrop: true,
47
+ canMove: true,
48
+ canResize: true,
49
+ canApplyManualOffset: true,
50
+ canApplyManualSize: true,
51
+ canApplyManualRotation: true,
52
+ },
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ function expectOk<T>(result: { ok: boolean } & Record<string, unknown>): T {
58
+ if (!result.ok) throw new Error(`expected ok, got ${JSON.stringify(result)}`);
59
+ return result as unknown as T;
60
+ }
61
+
62
+ describe("buildStudioLook", () => {
63
+ it("reports the playhead, duration and undo label an agent needs to checkpoint", () => {
64
+ const look = expectOk<{
65
+ playhead: number;
66
+ duration: number;
67
+ history: { undoLabel: string | null };
68
+ }>(
69
+ buildStudioLook(
70
+ snapshot({
71
+ currentTime: 2.4,
72
+ duration: 30,
73
+ history: { canUndo: true, canRedo: false, undoLabel: "Edit text", redoLabel: null },
74
+ }),
75
+ ),
76
+ );
77
+
78
+ expect(look.playhead).toBe(2.4);
79
+ expect(look.duration).toBe(30);
80
+ expect(look.history.undoLabel).toBe("Edit text");
81
+ });
82
+
83
+ it("gives every addressable element a handle a write tool can consume", () => {
84
+ const look = expectOk<{ elements: { handle: string | null; label: string | null }[] }>(
85
+ buildStudioLook(
86
+ snapshot({
87
+ elements: [
88
+ element({ hfId: "abc", label: "Headline" }),
89
+ element({ domId: "cta", label: "Button" }),
90
+ element({ selector: ".card", selectorIndex: 2, label: "Card" }),
91
+ ],
92
+ }),
93
+ ),
94
+ );
95
+
96
+ expect(look.elements.map((e) => e.handle)).toEqual(["hf:abc", "dom:cta", "sel:.card#2"]);
97
+ });
98
+
99
+ it("reports an unaddressable element with a null handle rather than hiding it", () => {
100
+ const look = expectOk<{ elements: { handle: string | null }[]; elementCount: number }>(
101
+ buildStudioLook(snapshot({ elements: [element({ label: "Anonymous" })] })),
102
+ );
103
+
104
+ expect(look.elementCount).toBe(1);
105
+ expect(look.elements[0]?.handle).toBeNull();
106
+ });
107
+
108
+ it("returns an empty list for an empty timeline, not a failure", () => {
109
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
110
+ buildStudioLook(snapshot()),
111
+ );
112
+
113
+ expect(look.elements).toEqual([]);
114
+ expect(look.elementCount).toBe(0);
115
+ });
116
+
117
+ it("filters on label, tag and handle, case-insensitively", () => {
118
+ const elements = [
119
+ element({ hfId: "abc", label: "Headline", tag: "h1" }),
120
+ element({ domId: "cta", label: "Button", tag: "button" }),
121
+ ];
122
+
123
+ const byLabel = expectOk<{ elements: { handle: string | null }[] }>(
124
+ buildStudioLook(snapshot({ elements }), { filter: "HEADLINE" }),
125
+ );
126
+ const byTag = expectOk<{ elements: { handle: string | null }[] }>(
127
+ buildStudioLook(snapshot({ elements }), { filter: "button" }),
128
+ );
129
+ const byHandle = expectOk<{ elements: { handle: string | null }[] }>(
130
+ buildStudioLook(snapshot({ elements }), { filter: "hf:abc" }),
131
+ );
132
+
133
+ expect(byLabel.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
134
+ expect(byTag.elements.map((e) => e.handle)).toEqual(["dom:cta"]);
135
+ expect(byHandle.elements.map((e) => e.handle)).toEqual(["hf:abc"]);
136
+ });
137
+
138
+ it("bounds a filter before normalizing it", () => {
139
+ const boundedFilter = "x".repeat(128);
140
+ const look = expectOk<{ elements: { handle: string | null }[] }>(
141
+ buildStudioLook(
142
+ snapshot({ elements: [element({ domId: "bounded", label: boundedFilter })] }),
143
+ { filter: `${boundedFilter}${"y".repeat(10_000)}` },
144
+ ),
145
+ );
146
+
147
+ expect(look.elements.map((entry) => entry.handle)).toEqual(["dom:bounded"]);
148
+ expect(STUDIO_LOOK_INPUT_SCHEMA.properties.filter.maxLength).toBe(128);
149
+ });
150
+
151
+ it("keeps the true match count when the list is truncated", () => {
152
+ const elements = Array.from({ length: 5 }, (_, index) =>
153
+ element({ domId: `el-${index}`, label: "Card" }),
154
+ );
155
+
156
+ const look = expectOk<{ elements: unknown[]; elementCount: number }>(
157
+ buildStudioLook(snapshot({ elements }), { limit: 2 }),
158
+ );
159
+
160
+ // A truncated list must not read as "that is all there is".
161
+ expect(look.elements).toHaveLength(2);
162
+ expect(look.elementCount).toBe(5);
163
+ });
164
+
165
+ it("clamps a nonsense limit instead of failing the call", () => {
166
+ const elements = [element({ domId: "a" }), element({ domId: "b" })];
167
+
168
+ for (const limit of [0, -3, 1.5, Number.NaN]) {
169
+ const look = expectOk<{ elements: unknown[] }>(
170
+ buildStudioLook(snapshot({ elements }), { limit }),
171
+ );
172
+ expect(look.elements).toHaveLength(2);
173
+ }
174
+ });
175
+
176
+ it("surfaces the selection with its capabilities and a usable handle", () => {
177
+ const look = expectOk<{
178
+ selection: { handle: string | null; box: { width: number }; can: { editStyles: boolean } };
179
+ }>(buildStudioLook(snapshot({ selection: selection() })));
180
+
181
+ expect(look.selection?.handle).toBe("hf:abc123");
182
+ expect(look.selection?.box.width).toBe(880);
183
+ expect(look.selection?.can.editStyles).toBe(true);
184
+ });
185
+
186
+ it("reports the live animation count supplied outside the DOM selection", () => {
187
+ const look = expectOk<{ selection: { animationCount: number } | null }>(
188
+ buildStudioLook(snapshot({ selection: selection(), selectionAnimationCount: 3 })),
189
+ );
190
+
191
+ expect(look.selection?.animationCount).toBe(3);
192
+ });
193
+
194
+ it("passes the disabled reason through so the agent learns it from a read", () => {
195
+ const locked = selection({
196
+ capabilities: {
197
+ ...selection().capabilities,
198
+ canEditStyles: false,
199
+ canMove: false,
200
+ canApplyManualOffset: false,
201
+ reasonIfDisabled: "Element is inside a locked composition",
202
+ },
203
+ });
204
+
205
+ const look = expectOk<{
206
+ selection: { can: { editStyles: boolean; move: boolean; reasonIfDisabled: string | null } };
207
+ }>(buildStudioLook(snapshot({ selection: locked })));
208
+
209
+ expect(look.selection?.can.editStyles).toBe(false);
210
+ expect(look.selection?.can.move).toBe(false);
211
+ expect(look.selection?.can.reasonIfDisabled).toBe("Element is inside a locked composition");
212
+ });
213
+
214
+ it("reports null selection rather than an empty one when nothing is selected", () => {
215
+ const look = expectOk<{ selection: unknown }>(buildStudioLook(snapshot({ selection: null })));
216
+ expect(look.selection).toBeNull();
217
+ });
218
+
219
+ it("does not advertise write readiness before the real write gate exists", () => {
220
+ const look = expectOk<Record<string, unknown>>(buildStudioLook(snapshot()));
221
+
222
+ expect(look).not.toHaveProperty("canWrite");
223
+ expect(look).not.toHaveProperty("writeBlockedReason");
224
+ });
225
+ });