diff --git a/docs/guides/webmcp.mdx b/docs/guides/webmcp.mdx index 78848a384..02b08004a 100644 --- a/docs/guides/webmcp.mdx +++ b/docs/guides/webmcp.mdx @@ -14,14 +14,16 @@ Studio registers its own capabilities as WebMCP tools, so an AI agent running in ## What it looks like -With the tools available, an agent can do this without touching your files: +With the tools available, an agent can inspect the source-backed scene, make one targeted edit, and +check the result: ```text -studio_look -> the project, playhead, selection, and every element -studio_select hf:abc123 -> selects the headline, same as clicking it -studio_inspect -> its resolved styles, text, and animations -studio_set_style {"color":"red"} -> writes it, through Studio's own commit path -studio_frame 2.4 -> a PNG of the composition at 2.4 seconds +studio_look -> find the headline and copy its handle +studio_select {"handle":"hf:abc123"} -> share the target with the person in Studio +studio_inspect {"handle":"hf:abc123"} -> read its styles, text, and capabilities +studio_set_style {"handle":"hf:abc123", + "styles":{"color":"red"}} -> write that source-backed element +studio_frame {"time":2.4} -> capture the composition at 2.4 seconds ``` The last one matters most. It is what lets an agent judge a change instead of guessing at it. @@ -63,8 +65,9 @@ console.log(tools.map((tool) => tool.name)); feature-detecting it will mislead you. -On browsers without native support, Studio loads a polyfill so a WebMCP bridge extension can still -connect. Nothing is downloaded on a browser that has the API already. +On browsers without native support, Studio loads a bundled polyfill so a WebMCP bridge extension can +still connect. Nothing is downloaded on a browser that has the API already. When the Studio +preference is disabled, it registers no tools. ## What an agent can do @@ -72,11 +75,14 @@ connect. Nothing is downloaded on a browser that has the API already. | Tool | Answers | | --- | --- | -| `studio_look` | The open project and composition, the playhead, what you have selected, and every element with a handle | +| `studio_look` | The open project and composition, the playhead, selection, undo state, and a bounded source-backed scene in nested order | | `studio_inspect` | One element in full: resolved styles, text fields, box, animations, and what it will accept | | `studio_frame` | A PNG of the composition at any time | -`studio_look` gives every element a **handle**. Pass it back to any tool that edits an element. +Each element returned by `studio_look` has a **handle**. It also reports `sourceFile`, `parentHandle`, +`depth`, and `childCount`, so an agent can distinguish the same authored ID in two nested scene +files. Pass a handle back unchanged to every element write. Handles are source-safe addresses, not +CSS selectors. ### Change @@ -92,29 +98,49 @@ connect. Nothing is downloaded on a browser that has the API already. | `studio_add_keyframe` | Adds a keyframe to an animation | | `studio_delete_animation` | Removes an animation | -Every edit runs through the same commit path a mouse gesture uses, so it lands in your file with the -same undo entry and the same save behaviour. There is no separate agent write path. +Element writes run through the same commit actors Studio uses. When that actor forwards versioned +durability evidence, the receipt names the source file and content version, and the edit enters the +same undo history. Actors without that evidence stay at `dispatched`. ## Two rules worth knowing -**Select first, then edit.** Most editing tools act on the current selection rather than taking an -element. That is how Studio itself works: click, then type. An agent that edits without selecting -gets an error telling it to select. +**Show intent, then address every write explicitly.** Before the first write to a target, call +`studio_select` so the person in Studio sees the same selection box and inspector as the agent. +Selection communicates intent; it does not grant write authority. `studio_set_text`, +`studio_set_style`, `studio_transform`, and the animation tools still require that target's handle +from `studio_look`, so a later human click cannot redirect an already-addressed write. -**Check what came back.** Tools report what actually happened, not what was asked for. -`studio_transform` reads the element's box back after writing and tells you which operations took -effect. `studio_frame` reports the time it actually captured. When something could not be verified, -the tool says so rather than claiming success. +**Read the receipt stage.** A write result separates acceptance from proof: + +| Stage | What it proves | +| --- | --- | +| `refused` | No commit actor ran. Fix the handle, input, capability, or Studio state before retrying. | +| `dispatched` | The actor accepted the request, but the tool has no durable version or independent readback. Follow with `studio_inspect` or `studio_frame`. | +| `saved` | Studio received versioned evidence that the named source file persisted the write. | +| `verified` | The write was saved and an independent readback observed the result. | +| `failed` | A commit actor ran and then failed. Inspect `kind`, `reason`, and any `hint`. | + +`changed` is separate from the stage. A saved no-op is still truthful: the file accepted the request, +but the value was already present. Style writes include a receipt per property and can be partial. +Animation handlers currently report `dispatched` after their persistence and live-preview sync +settle; they do not claim versioned durability or independent readback when the underlying handler +cannot provide that evidence. A late cancellation request also does not undo an edit that was +already dispatched or saved. ## Working alongside an agent -This is built for you and an agent looking at the same composition. Studio shows you every change as -it happens: an agent selecting an element draws the same selection box, and an edit appears in your -undo history under its own name. +This is built for you and an agent looking at the same composition. Studio shows selection normally +and adds a **Topology Lens** around the addressed element while a transaction is resolving. A new +target is acquired, repeated edits localize more quickly, and a durable result seals. The lens is +Studio-only transaction feedback: it is not added to authored HTML, the preview iframe, captured +frames, or thumbnails. -That shared view is doing real work. Some of Studio's write paths report a failure through a toast -rather than a return value, so **you** are the one who sees it. Leave Studio visible while an agent -is working. +When a write tool finishes successfully, the open Studio preview has already synchronized that +edit. The person watching does not need to scrub the timeline or refresh the browser to see it. + +After an accepted source-file change, Studio advances the project thumbnail revision and regenerates +visible composition thumbnails. This lets the sidebar converge on the saved project instead of +continuing to show cached pre-edit pixels. Studio refuses agent writes while auto-save is paused or an external change to the file is waiting diff --git a/packages/studio-server/src/helpers/projectSignature.test.ts b/packages/studio-server/src/helpers/projectSignature.test.ts index d6d5afcdd..266173ef8 100644 --- a/packages/studio-server/src/helpers/projectSignature.test.ts +++ b/packages/studio-server/src/helpers/projectSignature.test.ts @@ -1,6 +1,23 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { + closeSync, + fstatSync, + ftruncateSync, + futimesSync, + mkdtempSync, + openSync, + rmSync, + writeSync, +} from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { affectsProjectSignature } from "./projectSignature.js"; +import { affectsProjectSignature, createProjectSignature } from "./projectSignature.js"; + +const temporaryProjects: string[] = []; + +afterEach(() => { + for (const project of temporaryProjects.splice(0)) rmSync(project, { recursive: true }); +}); const PROJECT = resolve("/projects/demo"); const affects = (relativePath: string) => @@ -42,3 +59,25 @@ describe("affectsProjectSignature", () => { expect(affectsProjectSignature(PROJECT, PROJECT)).toBe(false); }); }); + +describe("createProjectSignature", () => { + it("changes after same-size content is written with the original mtime restored", () => { + const project = mkdtempSync(resolve(tmpdir(), "hf-signature-")); + temporaryProjects.push(project); + const file = resolve(project, "index.html"); + const descriptor = openSync(file, "w+"); + try { + writeSync(descriptor, "first"); + const originalMtime = fstatSync(descriptor).mtime; + const before = createProjectSignature(project); + + ftruncateSync(descriptor, 0); + writeSync(descriptor, "other", 0, "utf8"); + futimesSync(descriptor, originalMtime, originalMtime); + + expect(createProjectSignature(project)).not.toBe(before); + } finally { + closeSync(descriptor); + } + }); +}); diff --git a/packages/studio-server/src/helpers/projectSignature.ts b/packages/studio-server/src/helpers/projectSignature.ts index 051616e11..c0a1bd520 100644 --- a/packages/studio-server/src/helpers/projectSignature.ts +++ b/packages/studio-server/src/helpers/projectSignature.ts @@ -70,6 +70,7 @@ export function affectsProjectSignature(projectDir: string, changedPath: string) interface ProjectSignatureFile { file: string; mtimeMs: number; + ctimeMs: number; size: number; textContentEligible: boolean; } @@ -124,6 +125,7 @@ function collectProjectSignatureFiles( files.push({ file, mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, size: stat.size, textContentEligible: isTextContentEligible(file, stat.size), }); @@ -149,6 +151,7 @@ function collectProjectSignatureManifestFiles( files.push({ file, mtimeMs: stat.mtimeMs, + ctimeMs: stat.ctimeMs, size: stat.size, textContentEligible: isTextContentEligible(file, stat.size), }); @@ -165,6 +168,8 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil hash.update("\0"); hash.update(String(entry.mtimeMs)); hash.update("\0"); + hash.update(String(entry.ctimeMs)); + hash.update("\0"); hash.update(entry.textContentEligible ? "text" : "binary"); hash.update("\0"); } diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index d3d42d3e2..0a68396a2 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -437,11 +437,15 @@ describe("registerFileRoutes", () => { const payload = (await response.json()) as { changed?: boolean; path?: string; + version?: string; backupPath?: string; }; expect(payload.changed).toBe(true); expect(payload.path).toBe("index.html"); + expect(payload.version).toBe( + fileContentVersion(readFileSync(join(projectDir, "index.html"), "utf-8")), + ); expect(payload.backupPath).toMatch(/^\.hyperframes\/backup\//); expect(readFileSync(join(projectDir, payload.backupPath!), "utf-8")).toBe( '
Before
', @@ -449,6 +453,43 @@ describe("registerFileRoutes", () => { expect(readFileSync(join(projectDir, "index.html"), "utf-8")).toContain("After"); }); + it("returns the current durable version for a matched no-op element patch", async () => { + const projectDir = createProjectDir(); + const original = '
Before
'; + writeFileSync(join(projectDir, "index.html"), original); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const response = await app.request( + "http://localhost/projects/demo/file-mutations/patch-element/index.html", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: "title" }, + operations: [{ type: "text-content", property: "textContent", value: "Before" }], + }), + }, + ); + const payload = (await response.json()) as { + changed?: boolean; + matched?: boolean; + path?: string; + version?: string; + backupPath?: string; + }; + + expect(response.status).toBe(200); + expect(payload).toMatchObject({ + changed: false, + matched: true, + path: "index.html", + version: fileContentVersion(original), + }); + expect(payload.backupPath).toBeUndefined(); + expect(existsSync(join(projectDir, ".hyperframes", "backup"))).toBe(false); + }); + // Without the receipt the client cannot recognise its own edit in the watcher // broadcast, so it treats it as someone else's write and does a full preview // reload — a visible blank on the stage right after the user typed. @@ -1292,6 +1333,30 @@ const tl = gsap.timeline({ paused: true }); expect(res.status).toBe(400); }); + it("rejects raw JavaScript expressions at the GSAP mutation boundary", async () => { + const projectDir = createProjectDir(); + writeHtml(projectDir, "comp.html", FROMTO_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const animation = await getFirstAnimation(app, "comp.html"); + + const response = await app.request("http://localhost/projects/demo/gsap-mutations/comp.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "update-meta", + animationId: animation.id, + updates: { ease: "__raw:(()=>alert(1))()" }, + }), + }); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + error: "raw JavaScript expressions are not accepted", + }); + expect(readFileSync(join(projectDir, "comp.html"), "utf8")).toBe(FROMTO_COMP); + }); + it("update-from-property updates a fromTo start value in place", async () => { const projectDir = createProjectDir(); writeHtml(projectDir, "comp.html", FROMTO_COMP); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index c45641a33..7c5d439be 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -1186,6 +1186,9 @@ function validateGsapMutationRequest( if (!body || typeof body !== "object" || !("type" in body) || !body.type) { return c.json({ error: "mutation type required" }, 400); } + if (containsRawGsapExpression(body)) { + return c.json({ error: "raw JavaScript expressions are not accepted" }, 400); + } const unsafeFields = findUnsafeMutationValues(body); if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields); if ( @@ -1197,6 +1200,13 @@ function validateGsapMutationRequest( return null; } +function containsRawGsapExpression(value: unknown): boolean { + if (typeof value === "string") return value.startsWith("__raw:"); + if (Array.isArray(value)) return value.some(containsRawGsapExpression); + if (!value || typeof value !== "object") return false; + return Object.values(value).some(containsRawGsapExpression); +} + async function prepareGsapMutationScript( c: RouteContext, res: ResolvedGsapFile, @@ -2769,27 +2779,32 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { parsed.body.operations, ); if (patched === originalContent) { + const version = fileContentVersion(originalContent); + c.header("ETag", version); return c.json({ ok: true, changed: false, matched, content: originalContent, path: ctx.filePath, + version, }); } - const { backupPath } = writeMutationResult( + const { backupPath, version } = writeMutationResult( c, ctx.project.dir, ctx.filePath, ctx.absPath, patched, ); + c.header("ETag", version); return c.json({ ok: true, changed: true, matched, content: patched, path: ctx.filePath, + version, backupPath, }); }); diff --git a/packages/studio-server/src/routes/thumbnail.test.ts b/packages/studio-server/src/routes/thumbnail.test.ts index e5101cfcd..c45b94491 100644 --- a/packages/studio-server/src/routes/thumbnail.test.ts +++ b/packages/studio-server/src/routes/thumbnail.test.ts @@ -14,6 +14,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { pruneThumbnailCache, registerThumbnailRoutes } from "./thumbnail"; import type { StudioApiAdapter } from "../types"; +import { createProjectSignature } from "../helpers/projectSignature.js"; const tempProjectDirs: string[] = []; @@ -320,6 +321,98 @@ describe("registerThumbnailRoutes", () => { expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2); }); + it("regenerates a parent thumbnail when nested composition HTML changes", async () => { + const adapter = createAdapter(); + const project = await adapter.resolveProject("demo"); + if (!project) throw new Error("missing project"); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + + writeFileSync( + join(project.dir, "index.html"), + `
`, + ); + const compositionsDir = join(project.dir, "compositions"); + mkdirSync(compositionsDir, { recursive: true }); + const nestedPath = join(compositionsDir, "nested.html"); + writeFileSync(nestedPath, `
before
`); + const url = "http://localhost/projects/demo/thumbnail/index.html?t=2&v=test"; + + await app.request(url); + writeFileSync(nestedPath, `
after with a different size
`); + await app.request(url); + + expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2); + }); + + it("regenerates a thumbnail when imported CSS changes", async () => { + const adapter = createAdapter(); + const project = await adapter.resolveProject("demo"); + if (!project) throw new Error("missing project"); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + + writeFileSync( + join(project.dir, "index.html"), + `
`, + ); + const stylesPath = join(project.dir, "styles.css"); + writeFileSync(stylesPath, `.card { color: red; }`); + const url = "http://localhost/projects/demo/thumbnail/index.html?t=2&v=test"; + + await app.request(url); + writeFileSync(stylesPath, `.card { color: rebeccapurple; }`); + await app.request(url); + + expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2); + }); + + it("uses the adapter-cached project signature for disk reuse and in-flight dedupe", async () => { + const adapter = createAdapter(); + const project = await adapter.resolveProject("demo"); + if (!project) throw new Error("missing project"); + const getProjectSignature = vi.fn(() => createProjectSignature(project.dir)); + adapter.getProjectSignature = getProjectSignature; + let resolve!: (buffer: Buffer) => void; + const generated = new Promise((done) => (resolve = done)); + adapter.generateThumbnail = vi.fn(async () => generated); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + const url = "http://localhost/projects/demo/thumbnail/index.html?t=3"; + + const first = app.request(url); + const duplicate = app.request(url); + await vi.waitFor(() => expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1)); + resolve(Buffer.from("shared")); + expect(await (await first).text()).toBe("shared"); + expect(await (await duplicate).text()).toBe("shared"); + expect(await (await app.request(url)).text()).toBe("shared"); + + expect(adapter.generateThumbnail).toHaveBeenCalledTimes(1); + expect(getProjectSignature).toHaveBeenCalledTimes(4); + expect(getProjectSignature).toHaveBeenNthCalledWith(1, project.dir); + }); + + it("does not cache generated pixels under a signature that changed in flight", async () => { + const adapter = createAdapter(); + const project = await adapter.resolveProject("demo"); + if (!project) throw new Error("missing project"); + const signatures = ["old", "new", "old", "old"]; + adapter.getProjectSignature = vi.fn(() => signatures.shift() ?? "old"); + adapter.generateThumbnail = vi + .fn() + .mockResolvedValueOnce(Buffer.from("rendered-after-change")) + .mockResolvedValueOnce(Buffer.from("rendered-old")); + const app = new Hono(); + registerThumbnailRoutes(app, adapter); + const url = "http://localhost/projects/demo/thumbnail/index.html?t=3"; + + expect(await (await app.request(url)).text()).toBe("rendered-after-change"); + expect(existsSync(join(project.dir, ".thumbnails"))).toBe(false); + expect(await (await app.request(url)).text()).toBe("rendered-old"); + expect(adapter.generateThumbnail).toHaveBeenCalledTimes(2); + }); + it("keeps changed studio motion separated in the disk cache", async () => { const adapter = createAdapter(); const project = await adapter.resolveProject("demo"); diff --git a/packages/studio-server/src/routes/thumbnail.ts b/packages/studio-server/src/routes/thumbnail.ts index 81ac04774..ab1a482fe 100644 --- a/packages/studio-server/src/routes/thumbnail.ts +++ b/packages/studio-server/src/routes/thumbnail.ts @@ -14,6 +14,7 @@ import { join } from "node:path"; import { createHash, randomUUID } from "node:crypto"; import type { StudioApiAdapter } from "../types.js"; import { STUDIO_MANUAL_EDITS_PATH } from "../helpers/manualEditsRenderScript.js"; +import { createProjectSignature, resolveProjectAndSignature } from "../helpers/projectSignature.js"; import { STUDIO_MOTION_PATH } from "../helpers/studioMotionRenderScript.js"; import { thumbnailGenerationCoordinator } from "./thumbnailGenerationCoordinator.js"; @@ -78,8 +79,9 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v if (!adapter.generateThumbnail) { return c.json({ error: "Thumbnails not available" }, 501); } - const project = await adapter.resolveProject(c.req.param("id")); - if (!project) return c.json({ error: "not found" }, 404); + const resolved = await resolveProjectAndSignature(adapter, c.req.param("id")); + if (!resolved) return c.json({ error: "not found" }, 404); + const { project, signature: projectSignature } = resolved; let compPath = decodeURIComponent( c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? "", @@ -162,6 +164,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : ""; + const projectSignatureKey = `_${createHash("sha1").update(projectSignature).digest("hex").slice(0, 16)}`; const outputScale = outputMode === "source" ? 1 @@ -174,7 +177,7 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH); const outputWidth = Math.max(1, Math.round(compW * outputScale)); const outputHeight = Math.max(1, Math.round(compH * outputScale)); - const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`; + const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${projectSignatureKey}${manualEditsKey}${motionKey}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`; const cachePath = join(cacheDir, cacheKey); if (!prunedCacheDirs.has(cacheDir)) { prunedCacheDirs.add(cacheDir); @@ -209,6 +212,19 @@ export function registerThumbnailRoutes(api: Hono, adapter: StudioApiAdapter): v signal, }); if (!generated) return null; + const afterGeneration = await resolveProjectAndSignature(adapter, project.id); + const freshSignature = createProjectSignature(project.dir); + if ( + !afterGeneration || + afterGeneration.project.dir !== project.dir || + afterGeneration.signature !== projectSignature || + freshSignature !== projectSignature + ) { + // The browser may have rendered content written after this request + // captured its cache identity. Return the pixels to this caller, + // but never file them under a signature they do not prove. + return generated; + } if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true }); writeThumbnailAtomically(cachePath, generated); return generated; diff --git a/packages/studio/.gitignore b/packages/studio/.gitignore index 9012af4f9..b3fcb6c57 100644 --- a/packages/studio/.gitignore +++ b/packages/studio/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ data/projects/ +tests/e2e/evidence/ diff --git a/packages/studio/package.json b/packages/studio/package.json index f20937b13..6e9172fcf 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -49,6 +49,7 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:webmcp-edit-loop": "node tests/e2e/webmcp-edit-loop.mjs", "test:timeline-virtualization": "TIMELINE_ROW_VIRTUALIZATION=on TIMELINE_ELEMENT_COUNT=50000 node tests/e2e/timeline-virtualization.mjs", "test:watch": "vitest", "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts", diff --git a/packages/studio/src/components/editor/TopologyLens.test.tsx b/packages/studio/src/components/editor/TopologyLens.test.tsx new file mode 100644 index 000000000..54b7cb44b --- /dev/null +++ b/packages/studio/src/components/editor/TopologyLens.test.tsx @@ -0,0 +1,294 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { studioEditLifecycle, type StudioWriteResult } from "../../webmcp/writeCoordinator"; +import { TopologyLens } from "./TopologyLens"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const geometryMock = vi.hoisted(() => ({ measure: vi.fn() })); +vi.mock("./topologyLensGeometry", () => ({ + measureTopologyLensGeometry: geometryMock.measure, +})); + +const target = { handle: "dom:target", sourceFile: "index.html" }; +let root: Root | null = null; +let host: HTMLDivElement | null = null; +let iframe: HTMLIFrameElement | null = null; + +function matchMedia(reducedMotion: boolean): typeof window.matchMedia { + return vi.fn().mockReturnValue({ + matches: reducedMotion, + media: "(prefers-reduced-motion: reduce)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + }); +} + +function mount(reducedMotion = false, strictMode = false): void { + window.matchMedia = matchMedia(reducedMotion); + host = document.createElement("div"); + iframe = document.createElement("iframe"); + document.body.append(host, iframe); + root = createRoot(host); + act(() => { + const lens = ( + + ); + root?.render(strictMode ? {lens} : lens); + }); +} + +function begin(): string { + let callId = ""; + act(() => { + callId = studioEditLifecycle.begin("project-a", target, "set-text"); + }); + return callId; +} + +function finish( + callId: string, + stage: "dispatched" | "saved" | "verified" | "failed", + changed = true, +): void { + const result: StudioWriteResult = + stage === "failed" + ? { + ok: false, + kind: "failed", + reason: "save failed", + stage, + target, + operation: "set-text", + } + : { + ok: true, + stage, + target, + operation: "set-text", + changed, + evidence: + stage === "dispatched" + ? { kind: "dispatch", followUp: "studio_inspect" } + : { kind: "content-version", sourceFile: "index.html", version: "v1" }, + }; + act(() => studioEditLifecycle.finish(callId, result)); +} + +beforeEach(() => { + vi.useFakeTimers(); + geometryMock.measure.mockReset().mockReturnValue({ + field: { + label: "section", + rect: { left: 5, top: 10, width: 320, height: 180, editScaleX: 1, editScaleY: 1 }, + }, + target: { + label: "h1", + rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 }, + }, + contours: [ + { + label: "p", + rect: { left: 20, top: 30, width: 80, height: 20, editScaleX: 1, editScaleY: 1 }, + }, + { + label: "aside", + rect: { left: 110, top: 30, width: 60, height: 50, editScaleX: 1, editScaleY: 1 }, + }, + ], + }); +}); + +afterEach(() => { + if (root) act(() => root?.unmount()); + act(() => vi.runOnlyPendingTimers()); + root = null; + host = null; + iframe = null; + studioEditLifecycle.reset(); + document.body.replaceChildren(); + vi.useRealTimers(); +}); + +describe("TopologyLens", () => { + it("reveals real contours for a new target and seals only a durable receipt", () => { + mount(); + const callId = begin(); + + expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull(); + expect( + host?.querySelector('[data-topology-field="true"][data-topology-node="section"]'), + ).not.toBeNull(); + expect(host?.querySelector('[data-topology-target][data-topology-node="h1"]')).not.toBeNull(); + expect(host?.querySelectorAll('[data-topology-contour="true"]')).toHaveLength(2); + expect( + [...(host?.querySelectorAll("[data-topology-contour]") ?? [])].map( + (element) => element.dataset.topologyNode, + ), + ).toEqual(["p", "aside"]); + expect(host?.querySelector('[data-topology-scan="true"]')).not.toBeNull(); + + finish(callId, "saved"); + expect(host?.querySelector('[data-topology-lens="sealing"]')).not.toBeNull(); + expect( + host + ?.querySelector('[data-topology-field][data-topology-phase="sealing"]') + ?.querySelector('[data-topology-seal="saved"]'), + ).not.toBeNull(); + expect(host?.querySelector("[data-topology-target] [data-topology-seal]")).toBeNull(); + expect(geometryMock.measure).toHaveBeenCalledTimes(2); + + act(() => vi.advanceTimersByTime(240)); + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" }); + }); + + it("remeasures the post-write target before drawing the persistence seal", () => { + geometryMock.measure + .mockReturnValueOnce({ + field: { + label: "section", + rect: { left: 5, top: 10, width: 300, height: 180, editScaleX: 1, editScaleY: 1 }, + }, + target: { + label: "h1", + rect: { left: 10, top: 20, width: 200, height: 100, editScaleX: 1, editScaleY: 1 }, + }, + contours: [], + }) + .mockReturnValueOnce({ + field: { + label: "section", + rect: { left: 50, top: 20, width: 360, height: 220, editScaleX: 1, editScaleY: 1 }, + }, + target: { + label: "h1", + rect: { left: 70, top: 40, width: 240, height: 120, editScaleX: 1, editScaleY: 1 }, + }, + contours: [], + }); + mount(); + const callId = begin(); + expect(host?.querySelector("[data-topology-target]")?.style.left).toBe("10px"); + + finish(callId, "verified"); + + const sealedTarget = host?.querySelector("[data-topology-target]"); + expect(sealedTarget?.style.left).toBe("70px"); + expect(sealedTarget?.style.width).toBe("240px"); + expect(host?.querySelector('[data-topology-seal="verified"]')).not.toBeNull(); + }); + + it("skips acquisition when the coordinator identifies a repeated target", () => { + mount(); + const firstCall = begin(); + finish(firstCall, "saved"); + act(() => vi.advanceTimersByTime(240)); + + begin(); + + expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull(); + expect(host?.querySelector('[data-topology-contour="true"]')).toBeNull(); + }); + + it.each(["dispatched", "failed"] as const)("retracts %s without a seal", (stage) => { + mount(); + const callId = begin(); + + finish(callId, stage); + + expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull(); + expect(host?.querySelector("[data-topology-seal]")).toBeNull(); + act(() => vi.advanceTimersByTime(180)); + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" }); + }); + + it.each(["saved", "verified"] as const)("retracts a %s no-op without a seal", (stage) => { + mount(); + const callId = begin(); + + finish(callId, stage, false); + + expect(host?.querySelector('[data-topology-lens="localizing"]')).not.toBeNull(); + expect(host?.querySelector('[data-topology-terminal="no-change"]')).not.toBeNull(); + expect(host?.querySelector("[data-topology-seal]")).toBeNull(); + act(() => vi.advanceTimersByTime(180)); + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + }); + + it("does not replay a terminal seal after the overlay remounts", () => { + mount(); + const callId = begin(); + finish(callId, "saved"); + act(() => vi.advanceTimersByTime(240)); + expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" }); + + act(() => root?.unmount()); + root = null; + host?.remove(); + iframe?.remove(); + mount(); + + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + expect(host?.querySelector("[data-topology-seal]")).toBeNull(); + }); + + it("keeps an active invocation through StrictMode effect replay", () => { + const callId = begin(); + + mount(false, true); + act(() => vi.advanceTimersByTime(0)); + + expect(studioEditLifecycle.getSnapshot()).toMatchObject({ + callId, + phase: "dispatching", + }); + expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull(); + }); + + it("clears on iframe reload and project switch", () => { + mount(); + begin(); + + act(() => iframe?.dispatchEvent(new Event("load"))); + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + + begin(); + act(() => studioEditLifecycle.activateProject("project-b")); + expect(host?.querySelector('[data-topology-lens="hidden"]')).not.toBeNull(); + }); + + it("removes spatial travel in reduced motion while preserving target state", () => { + mount(true); + begin(); + + expect(host?.querySelector('[data-topology-lens="acquiring"]')).not.toBeNull(); + expect(host?.querySelector('[data-topology-contour="true"]')).not.toBeNull(); + expect(host?.querySelector('[data-topology-scan="true"]')).toBeNull(); + }); + + it("cleans its timer and iframe listener on unmount", () => { + mount(); + const removeListener = vi.spyOn(iframe!, "removeEventListener"); + begin(); + expect(vi.getTimerCount()).toBe(1); + + act(() => root?.unmount()); + root = null; + + expect(vi.getTimerCount()).toBe(1); + expect(removeListener).toHaveBeenCalledWith("load", expect.any(Function)); + expect(studioEditLifecycle.getSnapshot()).toMatchObject({ phase: "dispatching" }); + act(() => vi.advanceTimersByTime(0)); + expect(vi.getTimerCount()).toBe(0); + expect(studioEditLifecycle.getSnapshot()).toEqual({ phase: "idle" }); + }); +}); diff --git a/packages/studio/src/components/editor/TopologyLens.tsx b/packages/studio/src/components/editor/TopologyLens.tsx new file mode 100644 index 000000000..bb0b1503d --- /dev/null +++ b/packages/studio/src/components/editor/TopologyLens.tsx @@ -0,0 +1,206 @@ +import { + useEffect, + useLayoutEffect, + useReducer, + useRef, + useState, + useSyncExternalStore, + type CSSProperties, + type RefObject, +} from "react"; +import { studioEditLifecycle } from "../../webmcp/writeCoordinator"; +import { HyperframesMark } from "../ui/HyperframesMark"; +import { measureTopologyLensGeometry, type TopologyLensGeometry } from "./topologyLensGeometry"; +import { reduceTopologyLens, type TopologyLensState } from "./topologyLensState"; + +const ACQUISITION_MS = 240; +const TERMINAL_RETRACT_MS = 180; +const SEAL_MS = 240; + +interface TopologyLensProps { + iframeRef: RefObject; + activeCompositionPath: string | null; +} + +interface MeasuredLens { + callId: string; + geometry: TopologyLensGeometry; +} + +function rectStyle(rect: TopologyLensGeometry["target"]["rect"]): CSSProperties { + return { left: rect.left, top: rect.top, width: rect.width, height: rect.height }; +} + +function getReducedMotion(): boolean { + return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; +} + +function subscribeReducedMotion(listener: () => void): () => void { + const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!query) return () => undefined; + query.addEventListener("change", listener); + return () => query.removeEventListener("change", listener); +} + +function useTopologyLensState(): TopologyLensState { + const lifecycle = useSyncExternalStore( + studioEditLifecycle.subscribe, + studioEditLifecycle.getSnapshot, + studioEditLifecycle.getSnapshot, + ); + const [state, dispatch] = useReducer( + reduceTopologyLens, + lifecycle, + (initialLifecycle): TopologyLensState => + reduceTopologyLens({ phase: "hidden" }, { type: "lifecycle", value: initialLifecycle }), + ); + + useEffect(() => dispatch({ type: "lifecycle", value: lifecycle }), [lifecycle]); + useEffect(() => { + if (state.phase === "hidden") return; + const delay = + state.phase === "acquiring" + ? ACQUISITION_MS + : state.phase === "sealing" + ? SEAL_MS + : state.terminal + ? TERMINAL_RETRACT_MS + : null; + if (delay === null) return; + const timeout = window.setTimeout(() => { + if (state.phase === "acquiring") { + dispatch({ type: "acquisition-elapsed", callId: state.callId }); + return; + } + studioEditLifecycle.dismiss(state.callId); + }, delay); + return () => window.clearTimeout(timeout); + }, [state]); + + return state; +} + +/** Studio-parent chrome driven by the same invocation and receipt as WebMCP. */ +export function TopologyLens({ iframeRef, activeCompositionPath }: TopologyLensProps) { + const overlayRef = useRef(null); + const state = useTopologyLensState(); + const reducedMotion = useSyncExternalStore(subscribeReducedMotion, getReducedMotion, () => false); + const [measured, setMeasured] = useState(null); + const callId = state.phase === "hidden" ? null : state.callId; + const handle = state.phase === "hidden" ? null : state.target.handle; + const phase = state.phase; + const ownedCallIdRef = useRef(callId); + const pendingUnmountDismissRef = useRef(null); + ownedCallIdRef.current = callId; + + useLayoutEffect(() => { + if (!callId || !handle) { + setMeasured(null); + return; + } + const overlay = overlayRef.current; + const iframe = iframeRef.current; + if (!overlay || !iframe) { + setMeasured(null); + return; + } + const geometry = measureTopologyLensGeometry({ + overlay, + iframe, + activeCompositionPath, + handle, + }); + setMeasured(geometry ? { callId, geometry } : null); + }, [activeCompositionPath, callId, handle, iframeRef, phase]); + + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe || !callId) return; + const dismiss = () => studioEditLifecycle.dismiss(callId); + iframe.addEventListener("load", dismiss); + return () => iframe.removeEventListener("load", dismiss); + }, [callId, iframeRef]); + + useEffect(() => { + if (pendingUnmountDismissRef.current !== null) { + window.clearTimeout(pendingUnmountDismissRef.current); + pendingUnmountDismissRef.current = null; + } + return () => { + const ownedCallId = ownedCallIdRef.current; + if (!ownedCallId) return; + pendingUnmountDismissRef.current = window.setTimeout(() => { + pendingUnmountDismissRef.current = null; + studioEditLifecycle.dismiss(ownedCallId); + }, 0); + }; + }, []); + + const geometry = measured?.callId === callId ? measured.geometry : null; + const visible = state.phase !== "hidden" && geometry; + return ( +