feat(editor): canvas-protocol client and store
The editor side of the ADR-028 canvas protocol: local .strict() zod schemas (runtime.ready, geometry.report, click.target agent events; render-frame reused from @repo/core-runner-protocol + geometry.request editor commands; direction-specific envelopes) and a client that pins targetOrigin in BOTH directions — outbound posts never use '*', inbound drops wrong-origin, wrong-source, and schema-invalid events before any handler runs. Shapes are deliberately small and reconcile with the adapter's agent script in stories 05/10. Unit-tested against a scripted agent double; zustand store holds registry + selection ONLY (DesignDoc v1 arrives with the design-doc epic, ADR-029). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
278
packages/editor/src/canvas-protocol/canvas-client.test.ts
Normal file
278
packages/editor/src/canvas-protocol/canvas-client.test.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
|
||||||
|
import {
|
||||||
|
createCanvasClient,
|
||||||
|
type CanvasClientHandlers,
|
||||||
|
} from "@/canvas-protocol/canvas-client";
|
||||||
|
import { ScriptedAgentDouble } from "@/canvas-protocol/scripted-agent.mock";
|
||||||
|
import { useEditorStore } from "@/store/editor-store";
|
||||||
|
|
||||||
|
const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
|
||||||
|
const EVIL_ORIGIN = "https://evil.example.com";
|
||||||
|
|
||||||
|
function setup(handlers: CanvasClientHandlers = {}) {
|
||||||
|
const agent = new ScriptedAgentDouble({ origin: ADAPTER_ORIGIN });
|
||||||
|
const client = createCanvasClient({
|
||||||
|
adapterOrigin: ADAPTER_ORIGIN,
|
||||||
|
getAgentWindow: () => agent.agentWindow,
|
||||||
|
editorWindow: agent.editorWindow,
|
||||||
|
handlers,
|
||||||
|
});
|
||||||
|
return { agent, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("createCanvasClient — origin pinning", () => {
|
||||||
|
it('rejects "*" as the adapter origin — wildcard defeats pinning', () => {
|
||||||
|
expect(() =>
|
||||||
|
createCanvasClient({
|
||||||
|
adapterOrigin: "*",
|
||||||
|
getAgentWindow: () => null,
|
||||||
|
}),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a non-URL origin", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCanvasClient({
|
||||||
|
adapterOrigin: "not-an-origin",
|
||||||
|
getAgentWindow: () => null,
|
||||||
|
}),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an origin with a path — must be a bare origin", () => {
|
||||||
|
expect(() =>
|
||||||
|
createCanvasClient({
|
||||||
|
adapterOrigin: "https://adapter.veect.test:5199/frame",
|
||||||
|
getAgentWindow: () => null,
|
||||||
|
}),
|
||||||
|
).toThrow(TypeError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts every outbound message with the pinned targetOrigin", () => {
|
||||||
|
const { agent, client } = setup();
|
||||||
|
client.start();
|
||||||
|
client.renderFrame("button");
|
||||||
|
client.requestGeometry();
|
||||||
|
expect(agent.posted).toHaveLength(2);
|
||||||
|
for (const post of agent.posted) {
|
||||||
|
expect(post.targetOrigin).toBe(ADAPTER_ORIGIN);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores inbound runtime.ready from a wrong origin", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const { agent, client } = setup({ onRuntimeReady });
|
||||||
|
client.start();
|
||||||
|
agent.postRuntimeReady({ origin: EVIL_ORIGIN });
|
||||||
|
expect(onRuntimeReady).not.toHaveBeenCalled();
|
||||||
|
agent.postRuntimeReady();
|
||||||
|
expect(onRuntimeReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores inbound click.target from a wrong origin", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { agent, client } = setup({ onClick });
|
||||||
|
client.start();
|
||||||
|
agent.postClick("el-1", { origin: EVIL_ORIGIN });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores events from a different source window on the right origin", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { agent, client } = setup({ onClick });
|
||||||
|
client.start();
|
||||||
|
agent.postClick("el-1", { source: { postMessage: vi.fn() } });
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCanvasClient — inbound message handling", () => {
|
||||||
|
it("dispatches runtime.ready to onRuntimeReady", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const { agent, client } = setup({ onRuntimeReady });
|
||||||
|
client.start();
|
||||||
|
agent.postRuntimeReady();
|
||||||
|
expect(onRuntimeReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches geometry.report targets to onGeometry", () => {
|
||||||
|
const onGeometry = vi.fn();
|
||||||
|
const { agent, client } = setup({ onGeometry });
|
||||||
|
client.start();
|
||||||
|
const targets = [
|
||||||
|
{ nodeId: "el-1", rect: { x: 10, y: 20, width: 300, height: 40 } },
|
||||||
|
];
|
||||||
|
agent.postGeometry(targets);
|
||||||
|
expect(onGeometry).toHaveBeenCalledExactlyOnceWith(targets);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dispatches click.target to onClick, including null for background", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { agent, client } = setup({ onClick });
|
||||||
|
client.start();
|
||||||
|
agent.postClick("el-7");
|
||||||
|
agent.postClick(null);
|
||||||
|
expect(onClick).toHaveBeenNthCalledWith(1, "el-7");
|
||||||
|
expect(onClick).toHaveBeenNthCalledWith(2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed payloads: garbage, near-misses, and editor-direction messages", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { agent, client } = setup({ onRuntimeReady, onClick });
|
||||||
|
client.start();
|
||||||
|
agent.postRaw("not an envelope");
|
||||||
|
agent.postRaw({ type: "runtime.ready" }); // bare message, no envelope
|
||||||
|
agent.postRaw({
|
||||||
|
protocolVersion: "999",
|
||||||
|
message: { type: "runtime.ready" },
|
||||||
|
});
|
||||||
|
agent.postRaw({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message: { type: "render-frame", componentId: "button" },
|
||||||
|
});
|
||||||
|
expect(onRuntimeReady).not.toHaveBeenCalled();
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives handlers being omitted", () => {
|
||||||
|
const { agent, client } = setup();
|
||||||
|
client.start();
|
||||||
|
expect(() => {
|
||||||
|
agent.postRuntimeReady();
|
||||||
|
agent.postGeometry([]);
|
||||||
|
agent.postClick(null);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCanvasClient — outbound messages", () => {
|
||||||
|
it("renderFrame posts a valid editor envelope requesting one Element", () => {
|
||||||
|
const { agent, client } = setup();
|
||||||
|
client.start();
|
||||||
|
expect(client.renderFrame("button")).toBe(true);
|
||||||
|
expect(agent.posted[0]?.data).toEqual({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message: { type: "render-frame", componentId: "button" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renderFrame includes prop overrides when given", () => {
|
||||||
|
const { agent, client } = setup();
|
||||||
|
client.renderFrame("button", { label: "Save" });
|
||||||
|
expect(agent.posted[0]?.data).toEqual({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message: {
|
||||||
|
type: "render-frame",
|
||||||
|
componentId: "button",
|
||||||
|
props: { label: "Save" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requestGeometry posts a geometry.request envelope", () => {
|
||||||
|
const { agent, client } = setup();
|
||||||
|
client.requestGeometry();
|
||||||
|
expect(agent.posted[0]?.data).toEqual({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message: { type: "geometry.request" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false without posting when the agent window is unavailable", () => {
|
||||||
|
const agent = new ScriptedAgentDouble({ origin: ADAPTER_ORIGIN });
|
||||||
|
const client = createCanvasClient({
|
||||||
|
adapterOrigin: ADAPTER_ORIGIN,
|
||||||
|
getAgentWindow: () => null,
|
||||||
|
editorWindow: agent.editorWindow,
|
||||||
|
});
|
||||||
|
expect(client.renderFrame("button")).toBe(false);
|
||||||
|
expect(client.requestGeometry()).toBe(false);
|
||||||
|
expect(agent.posted).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCanvasClient — lifecycle", () => {
|
||||||
|
it("start is idempotent — one listener no matter how many calls", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const { agent, client } = setup({ onRuntimeReady });
|
||||||
|
client.start();
|
||||||
|
client.start();
|
||||||
|
expect(agent.listenerCount).toBe(1);
|
||||||
|
agent.postRuntimeReady();
|
||||||
|
expect(onRuntimeReady).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop detaches the listener and is idempotent", () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
const { agent, client } = setup({ onClick });
|
||||||
|
client.start();
|
||||||
|
client.stop();
|
||||||
|
client.stop();
|
||||||
|
expect(agent.listenerCount).toBe(0);
|
||||||
|
agent.postClick("el-1");
|
||||||
|
expect(onClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not hear messages before start", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const { agent } = setup({ onRuntimeReady });
|
||||||
|
agent.postRuntimeReady();
|
||||||
|
expect(onRuntimeReady).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCanvasClient — default editor window", () => {
|
||||||
|
it("listens on the global window when editorWindow is not injected", () => {
|
||||||
|
const onRuntimeReady = vi.fn();
|
||||||
|
const client = createCanvasClient({
|
||||||
|
adapterOrigin: ADAPTER_ORIGIN,
|
||||||
|
getAgentWindow: () => null,
|
||||||
|
handlers: { onRuntimeReady },
|
||||||
|
});
|
||||||
|
client.start();
|
||||||
|
try {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
data: {
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message: { type: "runtime.ready" },
|
||||||
|
},
|
||||||
|
origin: ADAPTER_ORIGIN,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(onRuntimeReady).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
client.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("selection round-trip through the store", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent click report drives store selection; background click deselects", () => {
|
||||||
|
const { agent, client } = setup({
|
||||||
|
onClick: (nodeId) => useEditorStore.getState().select(nodeId),
|
||||||
|
});
|
||||||
|
client.start();
|
||||||
|
|
||||||
|
agent.postClick("el-42");
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBe("el-42");
|
||||||
|
|
||||||
|
agent.postClick(null);
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a wrong-origin click never reaches the store", () => {
|
||||||
|
const { agent, client } = setup({
|
||||||
|
onClick: (nodeId) => useEditorStore.getState().select(nodeId),
|
||||||
|
});
|
||||||
|
client.start();
|
||||||
|
agent.postClick("el-42", { origin: EVIL_ORIGIN });
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/editor/src/canvas-protocol/canvas-client.ts
Normal file
170
packages/editor/src/canvas-protocol/canvas-client.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
|
||||||
|
import {
|
||||||
|
canvasAgentEnvelopeSchema,
|
||||||
|
canvasEditorEnvelopeSchema,
|
||||||
|
type CanvasEditorMessage,
|
||||||
|
type GeometryTarget,
|
||||||
|
} from "./messages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor-side canvas-protocol client (ADR-028).
|
||||||
|
*
|
||||||
|
* Speaks the postMessage seam with the agent script inside one adapter
|
||||||
|
* iframe. `targetOrigin` is pinned in BOTH directions:
|
||||||
|
*
|
||||||
|
* - outbound: every `postMessage` passes the adapter origin, never `"*"`;
|
||||||
|
* - inbound: events whose `origin` is not the adapter origin are dropped
|
||||||
|
* before parsing, as are events from a different source window and
|
||||||
|
* payloads that fail the strict agent-envelope schema.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** The minimal surface of the iframe's content window the client posts to. */
|
||||||
|
export type AgentWindowLike = {
|
||||||
|
postMessage: (message: unknown, targetOrigin: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The minimal surface of the editor window the client listens on. */
|
||||||
|
export type EditorWindowLike = {
|
||||||
|
addEventListener: (
|
||||||
|
type: "message",
|
||||||
|
listener: (event: MessageEvent) => void,
|
||||||
|
) => void;
|
||||||
|
removeEventListener: (
|
||||||
|
type: "message",
|
||||||
|
listener: (event: MessageEvent) => void,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasClientHandlers = {
|
||||||
|
/** Agent runtime is up — safe to send `render-frame` (spec §9). */
|
||||||
|
onRuntimeReady?: () => void;
|
||||||
|
/** Fresh geometry report — reposition editor-side overlays. */
|
||||||
|
onGeometry?: (targets: readonly GeometryTarget[]) => void;
|
||||||
|
/** Click-target resolution; `null` means background (deselect). */
|
||||||
|
onClick?: (nodeId: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasClientOptions = {
|
||||||
|
/** The adapter's origin — the pinned `targetOrigin` for both directions. */
|
||||||
|
adapterOrigin: string;
|
||||||
|
/**
|
||||||
|
* Late-bound accessor for the iframe's content window; `null` while the
|
||||||
|
* frame is not mounted. Late binding lets the client outlive iframe
|
||||||
|
* remounts.
|
||||||
|
*/
|
||||||
|
getAgentWindow: () => AgentWindowLike | null;
|
||||||
|
/** Defaults to the global `window` — injectable for tests. */
|
||||||
|
editorWindow?: EditorWindowLike;
|
||||||
|
handlers?: CanvasClientHandlers;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasClient = {
|
||||||
|
/** Attach the message listener. Idempotent. */
|
||||||
|
start: () => void;
|
||||||
|
/** Detach the message listener. Idempotent. */
|
||||||
|
stop: () => void;
|
||||||
|
/**
|
||||||
|
* Ask the adapter's frame host to render one discovered component
|
||||||
|
* (schema from `@repo/core-runner-protocol`). Returns `false` when the
|
||||||
|
* agent window is not available yet.
|
||||||
|
*/
|
||||||
|
renderFrame: (
|
||||||
|
componentId: string,
|
||||||
|
props?: Record<string, unknown>,
|
||||||
|
) => boolean;
|
||||||
|
/** Ask the agent for a fresh `geometry.report`. */
|
||||||
|
requestGeometry: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that `origin` is a well-formed, pinnable web origin. `"*"` is
|
||||||
|
* rejected outright — the wildcard would defeat the ADR-028 pinning
|
||||||
|
* guarantee on the outbound direction.
|
||||||
|
*/
|
||||||
|
function assertPinnableOrigin(origin: string): void {
|
||||||
|
if (origin === "*") {
|
||||||
|
throw new TypeError(
|
||||||
|
'adapterOrigin must be a concrete origin — "*" defeats targetOrigin pinning (ADR-028)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(origin);
|
||||||
|
} catch {
|
||||||
|
throw new TypeError(`adapterOrigin is not a valid origin: "${origin}"`);
|
||||||
|
}
|
||||||
|
if (parsed.origin !== origin) {
|
||||||
|
throw new TypeError(
|
||||||
|
`adapterOrigin must be a bare origin (no path/trailing slash): "${origin}"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCanvasClient(options: CanvasClientOptions): CanvasClient {
|
||||||
|
const { adapterOrigin, getAgentWindow, handlers = {} } = options;
|
||||||
|
assertPinnableOrigin(adapterOrigin);
|
||||||
|
const editorWindow: EditorWindowLike =
|
||||||
|
options.editorWindow ?? (globalThis.window as unknown as EditorWindowLike);
|
||||||
|
|
||||||
|
let listening = false;
|
||||||
|
|
||||||
|
const onMessage = (event: MessageEvent): void => {
|
||||||
|
// Inbound pin: only the adapter origin may speak to this client.
|
||||||
|
if (event.origin !== adapterOrigin) return;
|
||||||
|
// Source pin: when both sides are known, they must be the same window.
|
||||||
|
const agentWindow = getAgentWindow();
|
||||||
|
if (
|
||||||
|
agentWindow !== null &&
|
||||||
|
event.source !== null &&
|
||||||
|
event.source !== agentWindow
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = canvasAgentEnvelopeSchema.safeParse(event.data);
|
||||||
|
if (!parsed.success) return;
|
||||||
|
const message = parsed.data.message;
|
||||||
|
switch (message.type) {
|
||||||
|
case "runtime.ready":
|
||||||
|
handlers.onRuntimeReady?.();
|
||||||
|
return;
|
||||||
|
case "geometry.report":
|
||||||
|
handlers.onGeometry?.(message.targets);
|
||||||
|
return;
|
||||||
|
case "click.target":
|
||||||
|
handlers.onClick?.(message.nodeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = (message: CanvasEditorMessage): boolean => {
|
||||||
|
const agentWindow = getAgentWindow();
|
||||||
|
if (agentWindow === null) return false;
|
||||||
|
// Self-check outbound shape, then post with the pinned targetOrigin.
|
||||||
|
const envelope = canvasEditorEnvelopeSchema.parse({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
agentWindow.postMessage(envelope, adapterOrigin);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: () => {
|
||||||
|
if (listening) return;
|
||||||
|
editorWindow.addEventListener("message", onMessage);
|
||||||
|
listening = true;
|
||||||
|
},
|
||||||
|
stop: () => {
|
||||||
|
if (!listening) return;
|
||||||
|
editorWindow.removeEventListener("message", onMessage);
|
||||||
|
listening = false;
|
||||||
|
},
|
||||||
|
renderFrame: (componentId, props) =>
|
||||||
|
send({
|
||||||
|
type: "render-frame",
|
||||||
|
componentId,
|
||||||
|
...(props === undefined ? {} : { props }),
|
||||||
|
}),
|
||||||
|
requestGeometry: () => send({ type: "geometry.request" }),
|
||||||
|
};
|
||||||
|
}
|
||||||
147
packages/editor/src/canvas-protocol/messages.test.ts
Normal file
147
packages/editor/src/canvas-protocol/messages.test.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
|
||||||
|
import {
|
||||||
|
canvasAgentEnvelopeSchema,
|
||||||
|
canvasAgentMessageSchema,
|
||||||
|
canvasEditorEnvelopeSchema,
|
||||||
|
canvasEditorMessageSchema,
|
||||||
|
geometryTargetSchema,
|
||||||
|
rectSchema,
|
||||||
|
} from "@/canvas-protocol/messages";
|
||||||
|
|
||||||
|
const agentEnvelope = (message: unknown) => ({
|
||||||
|
protocolVersion: PROTOCOL_VERSION,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canvasAgentMessageSchema", () => {
|
||||||
|
it("round-trips runtime.ready", () => {
|
||||||
|
const msg = { type: "runtime.ready" } as const;
|
||||||
|
expect(canvasAgentMessageSchema.parse(msg)).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips a geometry.report with targets", () => {
|
||||||
|
const msg = {
|
||||||
|
type: "geometry.report",
|
||||||
|
targets: [
|
||||||
|
{ nodeId: "el-1", rect: { x: 0, y: 12.5, width: 320, height: 48 } },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
expect(canvasAgentMessageSchema.parse(msg)).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("round-trips click.target with a node id and with null (background)", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentMessageSchema.parse({ type: "click.target", nodeId: "el-1" }),
|
||||||
|
).toEqual({ type: "click.target", nodeId: "el-1" });
|
||||||
|
expect(
|
||||||
|
canvasAgentMessageSchema.parse({ type: "click.target", nodeId: null }),
|
||||||
|
).toEqual({ type: "click.target", nodeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown message types", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentMessageSchema.safeParse({ type: "hover.target" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects editor-direction messages — direction unions do not overlap", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentMessageSchema.safeParse({
|
||||||
|
type: "render-frame",
|
||||||
|
componentId: "button",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canvasEditorMessageSchema.safeParse({ type: "runtime.ready" }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is strict: unknown fields are rejected", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentMessageSchema.safeParse({ type: "runtime.ready", extra: 1 })
|
||||||
|
.success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rectSchema / geometryTargetSchema", () => {
|
||||||
|
it("rejects negative dimensions", () => {
|
||||||
|
expect(
|
||||||
|
rectSchema.safeParse({ x: 0, y: 0, width: -1, height: 10 }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-finite coordinates", () => {
|
||||||
|
expect(
|
||||||
|
rectSchema.safeParse({ x: Infinity, y: 0, width: 1, height: 1 }).success,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
rectSchema.safeParse({ x: 0, y: NaN, width: 1, height: 1 }).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty nodeId", () => {
|
||||||
|
expect(
|
||||||
|
geometryTargetSchema.safeParse({
|
||||||
|
nodeId: "",
|
||||||
|
rect: { x: 0, y: 0, width: 1, height: 1 },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canvasEditorMessageSchema", () => {
|
||||||
|
it("accepts render-frame (shared schema from @repo/core-runner-protocol)", () => {
|
||||||
|
const msg = {
|
||||||
|
type: "render-frame",
|
||||||
|
componentId: "button",
|
||||||
|
props: { label: "Save" },
|
||||||
|
} as const;
|
||||||
|
expect(canvasEditorMessageSchema.parse(msg)).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts render-frame without props (default props)", () => {
|
||||||
|
const msg = { type: "render-frame", componentId: "button" } as const;
|
||||||
|
expect(canvasEditorMessageSchema.parse(msg)).toEqual(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts geometry.request", () => {
|
||||||
|
expect(
|
||||||
|
canvasEditorMessageSchema.parse({ type: "geometry.request" }),
|
||||||
|
).toEqual({ type: "geometry.request" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("envelopes", () => {
|
||||||
|
it("agent envelope round-trips a valid message", () => {
|
||||||
|
const envelope = agentEnvelope({ type: "runtime.ready" });
|
||||||
|
expect(canvasAgentEnvelopeSchema.parse(envelope)).toEqual(envelope);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a foreign protocol version", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentEnvelopeSchema.safeParse({
|
||||||
|
protocolVersion: "999",
|
||||||
|
message: { type: "runtime.ready" },
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unknown envelope fields (no token at this seam — the origin pair is the trust boundary)", () => {
|
||||||
|
expect(
|
||||||
|
canvasAgentEnvelopeSchema.safeParse({
|
||||||
|
...agentEnvelope({ type: "runtime.ready" }),
|
||||||
|
token: "smuggled",
|
||||||
|
}).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("editor envelope rejects agent-direction messages", () => {
|
||||||
|
expect(
|
||||||
|
canvasEditorEnvelopeSchema.safeParse(
|
||||||
|
agentEnvelope({ type: "click.target", nodeId: "el-1" }),
|
||||||
|
).success,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
145
packages/editor/src/canvas-protocol/messages.ts
Normal file
145
packages/editor/src/canvas-protocol/messages.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
PROTOCOL_VERSION,
|
||||||
|
renderFrameMessageSchema,
|
||||||
|
} from "@repo/core-runner-protocol";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canvas-protocol message set, editor side (ADR-028).
|
||||||
|
*
|
||||||
|
* The canvas protocol is the postMessage seam between the editor and the
|
||||||
|
* Veect agent script injected into the adapter-served iframe. Chrome
|
||||||
|
* (selection/hover rings) renders as editor-side overlays from geometry the
|
||||||
|
* agent reports — nothing Veect-authored renders inside the customer's
|
||||||
|
* document, and `targetOrigin` is pinned in BOTH directions.
|
||||||
|
*
|
||||||
|
* These schemas are local to `@repo/editor` for now: the adapter's agent
|
||||||
|
* script (walking-skeleton story 05) delivers the other side of this seam,
|
||||||
|
* and the shapes reconcile into `@repo/core-runner-protocol` when the two
|
||||||
|
* meet in story 10. They are deliberately small. `render-frame` is already
|
||||||
|
* shared — it comes verbatim from `@repo/core-runner-protocol`, which is
|
||||||
|
* why its `type` literal is kebab-case while the agent events use the tech
|
||||||
|
* spec's dot-namespaced names (`runtime.ready`, spec §9).
|
||||||
|
*
|
||||||
|
* There is no auth token at this seam — the pinned origin pair is the trust
|
||||||
|
* boundary (the workspace-scoped token lives on the runner WS envelope).
|
||||||
|
* All object schemas are `.strict()` so unknown fields are rejected.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// --- Geometry ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** A rectangle in the iframe document's CSS pixel space. */
|
||||||
|
export const rectSchema = z
|
||||||
|
.object({
|
||||||
|
x: z.number().finite(),
|
||||||
|
y: z.number().finite(),
|
||||||
|
width: z.number().finite().nonnegative(),
|
||||||
|
height: z.number().finite().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type Rect = z.infer<typeof rectSchema>;
|
||||||
|
|
||||||
|
/** One measured node: the agent-assigned node id and its bounding rect. */
|
||||||
|
export const geometryTargetSchema = z
|
||||||
|
.object({
|
||||||
|
nodeId: z.string().min(1),
|
||||||
|
rect: rectSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GeometryTarget = z.infer<typeof geometryTargetSchema>;
|
||||||
|
|
||||||
|
// --- Agent -> editor -----------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The injected agent script announces the iframe runtime is up and the
|
||||||
|
* agent is listening (spec §9: `runtime.ready` queue-drain — the editor
|
||||||
|
* sends `render-frame` only after this).
|
||||||
|
*/
|
||||||
|
export const runtimeReadyMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("runtime.ready"),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type RuntimeReadyMessage = z.infer<typeof runtimeReadyMessageSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geometry report: bounding rects for the rendered nodes, measured by the
|
||||||
|
* agent post-`fonts.loaded` (ADR-028). The editor renders selection/hover
|
||||||
|
* overlays from these — it never measures across the origin boundary.
|
||||||
|
*/
|
||||||
|
export const geometryReportMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("geometry.report"),
|
||||||
|
targets: z.array(geometryTargetSchema),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GeometryReportMessage = z.infer<typeof geometryReportMessageSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click-target report: the agent hit-tests clicks inside the iframe and
|
||||||
|
* reports the resolved node id. `null` means background — deselect.
|
||||||
|
*/
|
||||||
|
export const clickTargetMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("click.target"),
|
||||||
|
nodeId: z.string().min(1).nullable(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ClickTargetMessage = z.infer<typeof clickTargetMessageSchema>;
|
||||||
|
|
||||||
|
export const canvasAgentMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
runtimeReadyMessageSchema,
|
||||||
|
geometryReportMessageSchema,
|
||||||
|
clickTargetMessageSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type CanvasAgentMessage = z.infer<typeof canvasAgentMessageSchema>;
|
||||||
|
|
||||||
|
// --- Editor -> agent -----------------------------------------------------
|
||||||
|
|
||||||
|
/** Ask the agent to (re-)measure and send a fresh `geometry.report`. */
|
||||||
|
export const geometryRequestMessageSchema = z
|
||||||
|
.object({
|
||||||
|
type: z.literal("geometry.request"),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type GeometryRequestMessage = z.infer<
|
||||||
|
typeof geometryRequestMessageSchema
|
||||||
|
>;
|
||||||
|
|
||||||
|
export const canvasEditorMessageSchema = z.discriminatedUnion("type", [
|
||||||
|
renderFrameMessageSchema,
|
||||||
|
geometryRequestMessageSchema,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type CanvasEditorMessage = z.infer<typeof canvasEditorMessageSchema>;
|
||||||
|
|
||||||
|
// --- Envelopes (direction-specific) --------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direction-specific envelopes: an agent envelope can never smuggle an
|
||||||
|
* editor command and vice versa — each side parses only its inbound union.
|
||||||
|
*/
|
||||||
|
export const canvasAgentEnvelopeSchema = z
|
||||||
|
.object({
|
||||||
|
protocolVersion: z.literal(PROTOCOL_VERSION),
|
||||||
|
message: canvasAgentMessageSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CanvasAgentEnvelope = z.infer<typeof canvasAgentEnvelopeSchema>;
|
||||||
|
|
||||||
|
export const canvasEditorEnvelopeSchema = z
|
||||||
|
.object({
|
||||||
|
protocolVersion: z.literal(PROTOCOL_VERSION),
|
||||||
|
message: canvasEditorMessageSchema,
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type CanvasEditorEnvelope = z.infer<typeof canvasEditorEnvelopeSchema>;
|
||||||
97
packages/editor/src/canvas-protocol/scripted-agent.mock.ts
Normal file
97
packages/editor/src/canvas-protocol/scripted-agent.mock.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
|
||||||
|
import type { AgentWindowLike, EditorWindowLike } from "./canvas-client";
|
||||||
|
import type { CanvasAgentMessage, GeometryTarget } from "./messages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scripted agent double (PRD Testing decisions): stands in for the adapter
|
||||||
|
* iframe's injected agent script in unit and component tests.
|
||||||
|
*
|
||||||
|
* One double = one fake origin pair. It owns:
|
||||||
|
* - `agentWindow` — records every editor→agent post with its
|
||||||
|
* `targetOrigin`, so tests can assert outbound pinning;
|
||||||
|
* - `editorWindow` — a fake message target the client listens on, so the
|
||||||
|
* double can script agent→editor events with any origin/source
|
||||||
|
* (including hostile ones) without jsdom `MessageEvent` restrictions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PostedMessage = {
|
||||||
|
data: unknown;
|
||||||
|
targetOrigin: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostOverrides = {
|
||||||
|
/** Origin the event claims to come from. Defaults to the double's origin. */
|
||||||
|
origin?: string;
|
||||||
|
/** Source window of the event. Defaults to the double's agentWindow. */
|
||||||
|
source?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ScriptedAgentDouble {
|
||||||
|
readonly origin: string;
|
||||||
|
/** Every editor→agent post, in order, with the targetOrigin used. */
|
||||||
|
readonly posted: PostedMessage[] = [];
|
||||||
|
readonly agentWindow: AgentWindowLike;
|
||||||
|
readonly editorWindow: EditorWindowLike;
|
||||||
|
|
||||||
|
private readonly listeners = new Set<(event: MessageEvent) => void>();
|
||||||
|
|
||||||
|
constructor(options: { origin: string }) {
|
||||||
|
this.origin = options.origin;
|
||||||
|
this.agentWindow = {
|
||||||
|
postMessage: (data: unknown, targetOrigin: string) => {
|
||||||
|
this.posted.push({ data, targetOrigin });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
this.editorWindow = {
|
||||||
|
addEventListener: (_type, listener) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
},
|
||||||
|
removeEventListener: (_type, listener) => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of attached message listeners (asserts start/stop symmetry). */
|
||||||
|
get listenerCount(): number {
|
||||||
|
return this.listeners.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
postRuntimeReady(overrides?: PostOverrides): void {
|
||||||
|
this.postMessage({ type: "runtime.ready" }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
postGeometry(
|
||||||
|
targets: readonly GeometryTarget[],
|
||||||
|
overrides?: PostOverrides,
|
||||||
|
): void {
|
||||||
|
this.postMessage(
|
||||||
|
{ type: "geometry.report", targets: [...targets] },
|
||||||
|
overrides,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
postClick(nodeId: string | null, overrides?: PostOverrides): void {
|
||||||
|
this.postMessage({ type: "click.target", nodeId }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Script a well-formed agent envelope. */
|
||||||
|
postMessage(message: CanvasAgentMessage, overrides?: PostOverrides): void {
|
||||||
|
this.postRaw({ protocolVersion: PROTOCOL_VERSION, message }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Script an arbitrary payload — for malformed-message tests. */
|
||||||
|
postRaw(data: unknown, overrides?: PostOverrides): void {
|
||||||
|
const event = {
|
||||||
|
data,
|
||||||
|
origin: overrides?.origin ?? this.origin,
|
||||||
|
source:
|
||||||
|
overrides && "source" in overrides
|
||||||
|
? overrides.source
|
||||||
|
: this.agentWindow,
|
||||||
|
} as MessageEvent;
|
||||||
|
for (const listener of [...this.listeners]) {
|
||||||
|
listener(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,35 @@
|
|||||||
// Public contract surface of @repo/editor (types, schemas, manifest).
|
// Public contract surface of @repo/editor (types, schemas, the canvas
|
||||||
// UI artifacts (board, frame node, overlay, store hook) live behind
|
// protocol seam, the manifest). UI artifacts (board, frame node, overlay,
|
||||||
// `@repo/editor/ui` — see src/ui/index.ts.
|
// store hook) live behind `@repo/editor/ui` — see src/ui/index.ts.
|
||||||
export { editorManifest, type EditorManifest } from "./feature.manifest";
|
export { editorManifest, type EditorManifest } from "./feature.manifest";
|
||||||
|
export {
|
||||||
|
rectSchema,
|
||||||
|
type Rect,
|
||||||
|
geometryTargetSchema,
|
||||||
|
type GeometryTarget,
|
||||||
|
runtimeReadyMessageSchema,
|
||||||
|
type RuntimeReadyMessage,
|
||||||
|
geometryReportMessageSchema,
|
||||||
|
type GeometryReportMessage,
|
||||||
|
clickTargetMessageSchema,
|
||||||
|
type ClickTargetMessage,
|
||||||
|
canvasAgentMessageSchema,
|
||||||
|
type CanvasAgentMessage,
|
||||||
|
geometryRequestMessageSchema,
|
||||||
|
type GeometryRequestMessage,
|
||||||
|
canvasEditorMessageSchema,
|
||||||
|
type CanvasEditorMessage,
|
||||||
|
canvasAgentEnvelopeSchema,
|
||||||
|
type CanvasAgentEnvelope,
|
||||||
|
canvasEditorEnvelopeSchema,
|
||||||
|
type CanvasEditorEnvelope,
|
||||||
|
} from "./canvas-protocol/messages";
|
||||||
|
export {
|
||||||
|
createCanvasClient,
|
||||||
|
type CanvasClient,
|
||||||
|
type CanvasClientHandlers,
|
||||||
|
type CanvasClientOptions,
|
||||||
|
type AgentWindowLike,
|
||||||
|
type EditorWindowLike,
|
||||||
|
} from "./canvas-protocol/canvas-client";
|
||||||
|
export type { RegistryComponent } from "./store/editor-store";
|
||||||
|
|||||||
59
packages/editor/src/store/editor-store.test.ts
Normal file
59
packages/editor/src/store/editor-store.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import { useEditorStore, type RegistryComponent } from "@/store/editor-store";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useEditorStore", () => {
|
||||||
|
it("starts with an empty registry and no selection", () => {
|
||||||
|
expect(useEditorStore.getState().registry).toEqual([]);
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRegistry replaces the registry", () => {
|
||||||
|
const registry: RegistryComponent[] = [
|
||||||
|
{ id: "button", name: "Button" },
|
||||||
|
{ id: "card", name: "Card" },
|
||||||
|
];
|
||||||
|
useEditorStore.getState().setRegistry(registry);
|
||||||
|
expect(useEditorStore.getState().registry).toEqual(registry);
|
||||||
|
|
||||||
|
useEditorStore.getState().setRegistry([{ id: "badge", name: "Badge" }]);
|
||||||
|
expect(useEditorStore.getState().registry).toEqual([
|
||||||
|
{ id: "badge", name: "Badge" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setRegistry copies the input — later caller mutation does not leak in", () => {
|
||||||
|
const input: RegistryComponent[] = [{ id: "button", name: "Button" }];
|
||||||
|
useEditorStore.getState().setRegistry(input);
|
||||||
|
input.push({ id: "rogue", name: "Rogue" });
|
||||||
|
expect(useEditorStore.getState().registry).toEqual([
|
||||||
|
{ id: "button", name: "Button" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("select sets and clears the selected node id", () => {
|
||||||
|
useEditorStore.getState().select("el-1");
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBe("el-1");
|
||||||
|
useEditorStore.getState().select(null);
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("selection does not touch the registry and vice versa", () => {
|
||||||
|
useEditorStore.getState().setRegistry([{ id: "button", name: "Button" }]);
|
||||||
|
useEditorStore.getState().select("el-1");
|
||||||
|
useEditorStore.getState().setRegistry([]);
|
||||||
|
expect(useEditorStore.getState().selectedNodeId).toBe("el-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds registry + selection ONLY — no document tree until DesignDoc v1 (ADR-029)", () => {
|
||||||
|
expect(Object.keys(useEditorStore.getState()).sort()).toEqual([
|
||||||
|
"registry",
|
||||||
|
"select",
|
||||||
|
"selectedNodeId",
|
||||||
|
"setRegistry",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/editor/src/store/editor-store.ts
Normal file
36
packages/editor/src/store/editor-store.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The minimal editor store (walking-skeleton story 09): registry +
|
||||||
|
* selection ONLY. There is deliberately no document tree here — DesignDoc
|
||||||
|
* v1 and the view-model mapping layer arrive with the
|
||||||
|
* `design-doc-and-editor-foundation` epic (ADR-029).
|
||||||
|
*
|
||||||
|
* Geometry is NOT store state: agent-reported rects are frame-local
|
||||||
|
* (each frame node keeps its own report), keeping this store honest to
|
||||||
|
* its two concerns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One discovered component, as the runner's scan reports it (ADR-027). */
|
||||||
|
export type RegistryComponent = {
|
||||||
|
/** Registry id the frame's `render-frame` request references. */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable component name (e.g. "Button"). */
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EditorStoreState = {
|
||||||
|
/** Discovered components available to place on the board. */
|
||||||
|
registry: readonly RegistryComponent[];
|
||||||
|
/** Agent-assigned node id of the selected element; `null` = none. */
|
||||||
|
selectedNodeId: string | null;
|
||||||
|
setRegistry: (registry: readonly RegistryComponent[]) => void;
|
||||||
|
select: (nodeId: string | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useEditorStore = create<EditorStoreState>((set) => ({
|
||||||
|
registry: [],
|
||||||
|
selectedNodeId: null,
|
||||||
|
setRegistry: (registry) => set({ registry: [...registry] }),
|
||||||
|
select: (nodeId) => set({ selectedNodeId: nodeId }),
|
||||||
|
}));
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Public UI surface of @repo/editor: the React Flow board, iframe frame
|
// Public UI surface of @repo/editor: apps import components/hooks from
|
||||||
// node, selection overlay, and the editor store hook land here as the
|
// `@repo/editor/ui` and contracts from `@repo/editor`. The React Flow
|
||||||
// walking-skeleton slices ship. Apps import components/hooks from
|
// board, iframe frame node, and selection overlay land here with the
|
||||||
// `@repo/editor/ui` and contracts from `@repo/editor`.
|
// board-shell slice.
|
||||||
export {};
|
export { useEditorStore, type EditorStoreState } from "../store/editor-store";
|
||||||
|
|||||||
Reference in New Issue
Block a user