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:
2026-07-12 22:07:08 +02:00
parent c42eca5b80
commit c2f56ac8c9
9 changed files with 971 additions and 8 deletions

View 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();
});
});

View 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" }),
};
}

View 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);
});
});

View 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>;

View 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);
}
}
}

View File

@@ -1,4 +1,35 @@
// Public contract surface of @repo/editor (types, schemas, manifest).
// UI artifacts (board, frame node, overlay, store hook) live behind
// `@repo/editor/ui` — see src/ui/index.ts.
// Public contract surface of @repo/editor (types, schemas, the canvas
// protocol seam, the manifest). UI artifacts (board, frame node, overlay,
// store hook) live behind `@repo/editor/ui` — see src/ui/index.ts.
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";

View 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",
]);
});
});

View 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 }),
}));

View File

@@ -1,5 +1,5 @@
// Public UI surface of @repo/editor: the React Flow board, iframe frame
// node, selection overlay, and the editor store hook land here as the
// walking-skeleton slices ship. Apps import components/hooks from
// `@repo/editor/ui` and contracts from `@repo/editor`.
export {};
// Public UI surface of @repo/editor: apps import components/hooks from
// `@repo/editor/ui` and contracts from `@repo/editor`. The React Flow
// board, iframe frame node, and selection overlay land here with the
// board-shell slice.
export { useEditorStore, type EditorStoreState } from "../store/editor-store";