diff --git a/apps/storybook/.storybook/main.ts b/apps/storybook/.storybook/main.ts
index d90b0b5..1161851 100644
--- a/apps/storybook/.storybook/main.ts
+++ b/apps/storybook/.storybook/main.ts
@@ -2,7 +2,10 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
framework: "@storybook/react-vite",
- stories: ["../../../packages/core-ui/src/**/*.stories.@(ts|tsx)"],
+ stories: [
+ "../../../packages/core-ui/src/**/*.stories.@(ts|tsx)",
+ "../../../packages/editor/src/**/*.stories.@(ts|tsx)",
+ ],
addons: ["@storybook/addon-essentials"],
docs: {
autodocs: "tag",
diff --git a/packages/editor/src/canvas-protocol/scripted-agent.mock.ts b/packages/editor/src/canvas-protocol/scripted-agent.mock.ts
index 40f49a3..598fdb4 100644
--- a/packages/editor/src/canvas-protocol/scripted-agent.mock.ts
+++ b/packages/editor/src/canvas-protocol/scripted-agent.mock.ts
@@ -19,6 +19,25 @@ export type PostedMessage = {
targetOrigin: string;
};
+/**
+ * Script one agent envelope onto the REAL window (jsdom) — for component
+ * tests where the canvas client listens on the default editor window.
+ * `MessageEvent` carries the given origin explicitly; `source` stays
+ * `null`, which the client tolerates (it cannot know the source when the
+ * scripted agent is not a window).
+ */
+export function postAgentEnvelopeToWindow(
+ message: CanvasAgentMessage,
+ origin: string,
+): void {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { protocolVersion: PROTOCOL_VERSION, message },
+ origin,
+ }),
+ );
+}
+
type PostOverrides = {
/** Origin the event claims to come from. Defaults to the double's origin. */
origin?: string;
diff --git a/packages/editor/src/ui/components/editor-board.stories.tsx b/packages/editor/src/ui/components/editor-board.stories.tsx
new file mode 100644
index 0000000..702bd60
--- /dev/null
+++ b/packages/editor/src/ui/components/editor-board.stories.tsx
@@ -0,0 +1,49 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { useEditorStore } from "../../store/editor-store";
+import { EditorBoard } from "./editor-board";
+
+/**
+ * The infinite board (ADR-028): scroll pans, pinch zooms, the frame drags
+ * by its name tab — all pure React Flow transforms, zero canvas-protocol
+ * traffic. The frame hosts the adapter origin in an iframe; without a live
+ * adapter it stays in its honest "Starting preview…" state.
+ */
+const ADAPTER_ORIGIN = "https://adapter.veect.localhost:5199";
+
+const meta = {
+ title: "Editor/EditorBoard",
+ component: EditorBoard,
+ tags: ["autodocs"],
+ args: { adapterOrigin: ADAPTER_ORIGIN },
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const WithFrame: Story = {
+ decorators: [
+ (Story) => {
+ useEditorStore.setState({
+ registry: [{ id: "button", name: "Button" }],
+ selectedNodeId: null,
+ });
+ return ;
+ },
+ ],
+};
+
+export const EmptyRegistry: Story = {
+ decorators: [
+ (Story) => {
+ useEditorStore.setState({ registry: [], selectedNodeId: null });
+ return ;
+ },
+ ],
+};
diff --git a/packages/editor/src/ui/components/editor-board.test.tsx b/packages/editor/src/ui/components/editor-board.test.tsx
new file mode 100644
index 0000000..b4aa031
--- /dev/null
+++ b/packages/editor/src/ui/components/editor-board.test.tsx
@@ -0,0 +1,115 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { act } from "react";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock";
+import { useEditorStore } from "@/store/editor-store";
+import { mockReactFlow } from "./react-flow.mock";
+import { EditorBoard } from "./editor-board";
+
+const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
+
+mockReactFlow();
+
+function renderBoard() {
+ return render(
+
+
+
,
+ );
+}
+
+beforeEach(() => {
+ useEditorStore.setState({ registry: [], selectedNodeId: null });
+});
+
+describe("EditorBoard — empty registry", () => {
+ it("renders an empty board: canvas present, no frame", () => {
+ const { container } = renderBoard();
+ expect(screen.getByTestId("editor-board")).toBeInTheDocument();
+ expect(container.querySelector(".react-flow")).not.toBeNull();
+ expect(container.querySelector("iframe")).toBeNull();
+ });
+});
+
+describe("EditorBoard — one frame from the registry", () => {
+ beforeEach(() => {
+ useEditorStore.setState({
+ registry: [
+ { id: "button", name: "Button" },
+ { id: "card", name: "Card" },
+ ],
+ selectedNodeId: null,
+ });
+ });
+
+ it("hosts one iframe frame node for the FIRST discovered component", async () => {
+ renderBoard();
+ const iframe = (await screen.findByTitle(
+ "Button frame",
+ )) as HTMLIFrameElement;
+ expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN);
+ // walking-skeleton scope: a single frame — the second registry entry
+ // does not get a frame.
+ expect(screen.queryByTitle("Card frame")).not.toBeInTheDocument();
+ expect(screen.getByText("Button")).toBeInTheDocument();
+ });
+
+ it("marks the frame node draggable by its handle only", async () => {
+ const { container } = renderBoard();
+ await screen.findByTitle("Button frame");
+ expect(container.querySelector(".frame-drag-handle")).not.toBeNull();
+ });
+
+ it("clears the selection on pane click — editor-side, no protocol traffic", async () => {
+ const { container } = renderBoard();
+ const iframe = (await screen.findByTitle(
+ "Button frame",
+ )) as HTMLIFrameElement;
+ const post = vi.spyOn(iframe.contentWindow!, "postMessage");
+ act(() => useEditorStore.getState().select("el-1"));
+
+ const pane = container.querySelector(".react-flow__pane");
+ expect(pane).not.toBeNull();
+ fireEvent.click(pane!);
+
+ expect(useEditorStore.getState().selectedNodeId).toBeNull();
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("pan/zoom gestures on the pane produce zero canvas-protocol traffic (<16 ms budget, ADR-028)", async () => {
+ const { container } = renderBoard();
+ const iframe = (await screen.findByTitle(
+ "Button frame",
+ )) as HTMLIFrameElement;
+ const post = vi.spyOn(iframe.contentWindow!, "postMessage");
+ // Drain the ready handshake first so the assertion below isolates the
+ // gesture path.
+ act(() =>
+ postAgentEnvelopeToWindow({ type: "runtime.ready" }, ADAPTER_ORIGIN),
+ );
+ expect(post).toHaveBeenCalledTimes(2);
+ post.mockClear();
+
+ const pane = container.querySelector(".react-flow__pane")!;
+ fireEvent.wheel(pane, { deltaY: -120 }); // panOnScroll
+ fireEvent.wheel(pane, { deltaY: 80, ctrlKey: true }); // zoomOnPinch
+ // (Empty-pane mouse-drag panning can't be simulated in jsdom — it
+ // refuses MouseEvent's `view` member — but frame dragging is the same
+ // structural path: a pure rerender, proven protocol-silent in
+ // frame-node.test.tsx.)
+
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("resyncs the board when the registry changes", async () => {
+ renderBoard();
+ await screen.findByTitle("Button frame");
+
+ act(() =>
+ useEditorStore.getState().setRegistry([{ id: "badge", name: "Badge" }]),
+ );
+
+ expect(await screen.findByTitle("Badge frame")).toBeInTheDocument();
+ expect(screen.queryByTitle("Button frame")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/editor/src/ui/components/editor-board.tsx b/packages/editor/src/ui/components/editor-board.tsx
new file mode 100644
index 0000000..778317d
--- /dev/null
+++ b/packages/editor/src/ui/components/editor-board.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import {
+ applyNodeChanges,
+ Background,
+ BackgroundVariant,
+ ReactFlow,
+ type Node,
+ type NodeChange,
+ type NodeTypes,
+} from "@xyflow/react";
+import "@xyflow/react/dist/style.css";
+import { useEditorStore } from "../../store/editor-store";
+import { FrameNode, type FrameNodeData } from "./frame-node";
+
+/**
+ * The board shell (ADR-028): an infinite React Flow canvas whose frames are
+ * iframe containers. Pan/zoom and frame dragging are pure React Flow
+ * transforms on those containers — zero canvas-protocol traffic on drag
+ * (<16 ms/frame budget); only the frame's name tab drags
+ * (`dragHandle: ".frame-drag-handle"`).
+ *
+ * Walking-skeleton scope: one frame, rendering the FIRST discovered
+ * registry component from the editor store. An empty registry renders an
+ * empty board. Pane clicks clear the selection editor-side.
+ */
+type BoardNode = Node;
+
+const nodeTypes: NodeTypes = { frame: FrameNode };
+
+export type EditorBoardProps = {
+ /** The adapter's origin — pinned targetOrigin for every frame's canvas protocol. */
+ adapterOrigin: string;
+};
+
+export function EditorBoard({ adapterOrigin }: EditorBoardProps) {
+ const registry = useEditorStore((s) => s.registry);
+ const select = useEditorStore((s) => s.select);
+ const component = registry[0];
+
+ const initialNodes = useMemo(
+ () =>
+ component === undefined
+ ? []
+ : [
+ {
+ id: `frame:${component.id}`,
+ type: "frame",
+ position: { x: 0, y: 0 },
+ data: {
+ frame: {
+ name: component.name,
+ componentId: component.id,
+ adapterOrigin,
+ },
+ },
+ dragHandle: ".frame-drag-handle",
+ },
+ ],
+ [component, adapterOrigin],
+ );
+
+ const [nodes, setNodes] = useState(initialNodes);
+
+ // Resync when the registry (or adapter origin) changes.
+ useEffect(() => setNodes(initialNodes), [initialNodes]);
+
+ const onNodesChange = useCallback(
+ (changes: NodeChange[]) =>
+ setNodes((current) => applyNodeChanges(changes, current)),
+ [],
+ );
+
+ return (
+
+ select(null)}
+ fitView
+ fitViewOptions={{ padding: 0.18, maxZoom: 1 }}
+ minZoom={0.08}
+ maxZoom={2.5}
+ panOnScroll
+ zoomOnScroll={false}
+ zoomOnPinch
+ panOnDrag={[0, 1]}
+ nodesFocusable={false}
+ deleteKeyCode={null}
+ >
+
+
+
+ );
+}
diff --git a/packages/editor/src/ui/components/frame-node.stories.tsx b/packages/editor/src/ui/components/frame-node.stories.tsx
new file mode 100644
index 0000000..f0f4d90
--- /dev/null
+++ b/packages/editor/src/ui/components/frame-node.stories.tsx
@@ -0,0 +1,62 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { PROTOCOL_VERSION } from "@repo/core-runner-protocol";
+import type { CanvasAgentMessage } from "../../canvas-protocol/messages";
+import { FrameNode } from "./frame-node";
+
+/**
+ * One frame hosting the runner adapter's origin through a sandboxed
+ * cross-origin iframe (ADR-028). Storybook has no live adapter, so the
+ * frame shows its honest cold-start state — the explicit
+ * "Starting preview…" skeleton, never presented as the real render. The
+ * scripted story below plays the agent side of the canvas protocol to
+ * drive the ready → geometry → click selection round-trip.
+ */
+const ADAPTER_ORIGIN = "https://adapter.veect.localhost:5199";
+
+function agentSays(message: CanvasAgentMessage) {
+ window.dispatchEvent(
+ new MessageEvent("message", {
+ data: { protocolVersion: PROTOCOL_VERSION, message },
+ origin: ADAPTER_ORIGIN,
+ }),
+ );
+}
+
+const meta = {
+ title: "Editor/FrameNode",
+ component: FrameNode,
+ tags: ["autodocs"],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const StartingPreview: Story = {
+ args: {
+ data: {
+ frame: {
+ name: "Button",
+ componentId: "button",
+ adapterOrigin: ADAPTER_ORIGIN,
+ width: 640,
+ height: 400,
+ },
+ },
+ },
+};
+
+export const SelectionRoundTrip: Story = {
+ args: StartingPreview.args,
+ play: async () => {
+ // Scripted agent: runtime.ready → geometry.report → click.target.
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ agentSays({ type: "runtime.ready" });
+ agentSays({
+ type: "geometry.report",
+ targets: [
+ { nodeId: "el-1", rect: { x: 48, y: 40, width: 320, height: 72 } },
+ ],
+ });
+ agentSays({ type: "click.target", nodeId: "el-1" });
+ },
+};
diff --git a/packages/editor/src/ui/components/frame-node.test.tsx b/packages/editor/src/ui/components/frame-node.test.tsx
new file mode 100644
index 0000000..38fe491
--- /dev/null
+++ b/packages/editor/src/ui/components/frame-node.test.tsx
@@ -0,0 +1,197 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { act } from "react";
+import { render, screen } from "@testing-library/react";
+import { postAgentEnvelopeToWindow } from "@/canvas-protocol/scripted-agent.mock";
+import { useEditorStore } from "@/store/editor-store";
+import { FrameNode, type FrameNodeData } from "./frame-node";
+
+const ADAPTER_ORIGIN = "https://adapter.veect.test:5199";
+const EVIL_ORIGIN = "https://evil.example.com";
+
+const data: FrameNodeData = {
+ frame: {
+ name: "Button",
+ componentId: "button",
+ adapterOrigin: ADAPTER_ORIGIN,
+ width: 640,
+ height: 480,
+ },
+};
+
+function getIframe(): HTMLIFrameElement {
+ return screen.getByTitle("Button frame") as HTMLIFrameElement;
+}
+
+function spyOnAgentWindow() {
+ const contentWindow = getIframe().contentWindow;
+ if (contentWindow === null) throw new Error("iframe has no contentWindow");
+ return vi.spyOn(contentWindow, "postMessage");
+}
+
+function agentSays(message: Parameters[0]) {
+ act(() => postAgentEnvelopeToWindow(message, ADAPTER_ORIGIN));
+}
+
+beforeEach(() => {
+ useEditorStore.setState({ registry: [], selectedNodeId: null });
+});
+
+describe("FrameNode — cold start", () => {
+ it("hosts the adapter origin in a sandboxed iframe with a drag-handle tab", () => {
+ render();
+ const iframe = getIframe();
+ expect(iframe).toHaveAttribute("src", ADAPTER_ORIGIN);
+ expect(iframe).toHaveAttribute(
+ "sandbox",
+ "allow-scripts allow-same-origin",
+ );
+ expect(screen.getByTitle("Drag to move")).toHaveClass("frame-drag-handle");
+ expect(screen.getByText("Button")).toBeInTheDocument();
+ });
+
+ it('shows the explicit "Starting preview…" skeleton until runtime.ready (ADR-028)', () => {
+ render();
+ expect(screen.getByText("Starting preview…")).toBeInTheDocument();
+ });
+
+ it("sends nothing before runtime.ready — queue-drain semantics", () => {
+ render();
+ const post = spyOnAgentWindow();
+ expect(post).not.toHaveBeenCalled();
+ });
+});
+
+describe("FrameNode — runtime.ready", () => {
+ it("requests a single Element via render-frame (default props) then geometry, pinned to the adapter origin", () => {
+ render();
+ const post = spyOnAgentWindow();
+
+ agentSays({ type: "runtime.ready" });
+
+ expect(post).toHaveBeenCalledTimes(2);
+ expect(post).toHaveBeenNthCalledWith(
+ 1,
+ {
+ protocolVersion: "0",
+ message: { type: "render-frame", componentId: "button" },
+ },
+ ADAPTER_ORIGIN,
+ );
+ expect(post).toHaveBeenNthCalledWith(
+ 2,
+ {
+ protocolVersion: "0",
+ message: { type: "geometry.request" },
+ },
+ ADAPTER_ORIGIN,
+ );
+ expect(screen.queryByText("Starting preview…")).not.toBeInTheDocument();
+ });
+
+ it("ignores runtime.ready from a wrong origin — still starting, nothing sent", () => {
+ render();
+ const post = spyOnAgentWindow();
+
+ act(() =>
+ postAgentEnvelopeToWindow({ type: "runtime.ready" }, EVIL_ORIGIN),
+ );
+
+ expect(post).not.toHaveBeenCalled();
+ expect(screen.getByText("Starting preview…")).toBeInTheDocument();
+ });
+});
+
+describe("FrameNode — selection round-trip from reported geometry", () => {
+ it("click report selects in the store and the overlay renders at the reported rect", () => {
+ render();
+ agentSays({ type: "runtime.ready" });
+ agentSays({
+ type: "geometry.report",
+ targets: [
+ { nodeId: "el-1", rect: { x: 16, y: 32, width: 240, height: 56 } },
+ ],
+ });
+
+ expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
+
+ agentSays({ type: "click.target", nodeId: "el-1" });
+
+ expect(useEditorStore.getState().selectedNodeId).toBe("el-1");
+ expect(screen.getByTestId("selection-overlay")).toHaveStyle({
+ left: "16px",
+ top: "32px",
+ width: "240px",
+ height: "56px",
+ });
+ });
+
+ it("background click deselects and hides the overlay", () => {
+ render();
+ agentSays({ type: "runtime.ready" });
+ agentSays({
+ type: "geometry.report",
+ targets: [
+ { nodeId: "el-1", rect: { x: 0, y: 0, width: 10, height: 10 } },
+ ],
+ });
+ agentSays({ type: "click.target", nodeId: "el-1" });
+ expect(screen.getByTestId("selection-overlay")).toBeInTheDocument();
+
+ agentSays({ type: "click.target", nodeId: null });
+
+ expect(useEditorStore.getState().selectedNodeId).toBeNull();
+ expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
+ });
+
+ it("no overlay when the selected node has no reported geometry yet", () => {
+ render();
+ agentSays({ type: "runtime.ready" });
+ agentSays({ type: "click.target", nodeId: "el-unmeasured" });
+ expect(useEditorStore.getState().selectedNodeId).toBe("el-unmeasured");
+ expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
+ });
+});
+
+describe("FrameNode — drag path stays off the protocol", () => {
+ it("rerendering (as frame drag does) produces zero protocol traffic", () => {
+ const { rerender } = render();
+ const post = spyOnAgentWindow();
+ agentSays({ type: "runtime.ready" });
+ expect(post).toHaveBeenCalledTimes(2);
+
+ // React Flow drag rerenders the node with fresh props; data values
+ // are unchanged, so the canvas client must not be recreated and no
+ // message may be sent (<16 ms/frame budget, ADR-028).
+ rerender();
+ rerender();
+
+ expect(post).toHaveBeenCalledTimes(2);
+ });
+
+ it("stops listening on unmount — late agent messages no longer reach the store", () => {
+ const { unmount } = render();
+ agentSays({ type: "runtime.ready" });
+ unmount();
+
+ agentSays({ type: "click.target", nodeId: "el-late" });
+
+ expect(useEditorStore.getState().selectedNodeId).toBeNull();
+ });
+});
+
+describe("FrameNode — defaults", () => {
+ it("falls back to the 800x600 default viewport", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/800 × 600/)).toBeInTheDocument();
+ });
+});
diff --git a/packages/editor/src/ui/components/frame-node.tsx b/packages/editor/src/ui/components/frame-node.tsx
new file mode 100644
index 0000000..1b75eee
--- /dev/null
+++ b/packages/editor/src/ui/components/frame-node.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { createCanvasClient } from "../../canvas-protocol/canvas-client";
+import type { GeometryTarget } from "../../canvas-protocol/messages";
+import { useEditorStore } from "../../store/editor-store";
+import { SelectionOverlay } from "./selection-overlay";
+
+/**
+ * One frame on the board: a cross-origin iframe hosting the runner
+ * adapter's origin (ADR-028). The frame speaks the canvas protocol through
+ * `createCanvasClient` — `targetOrigin` pinned both directions — and:
+ *
+ * - waits for `runtime.ready` before requesting anything (queue-drain,
+ * spec §9), showing an explicit "Starting preview…" skeleton until then
+ * (never presented as the real render);
+ * - then asks the adapter to render a single Element — the discovered
+ * component with default props — via `render-frame`
+ * (`@repo/core-runner-protocol` schema) and requests geometry;
+ * - keeps agent-reported geometry as frame-local state (the store holds
+ * registry + selection ONLY) and feeds click-target reports into the
+ * store's selection;
+ * - renders the selection ring as an editor-side overlay from that
+ * reported geometry.
+ *
+ * Dragging the frame only rerenders with a new position — the canvas
+ * client is keyed on `adapterOrigin`/`componentId`, so drag/pan/zoom
+ * produce zero protocol round-trips (<16 ms/frame budget, ADR-028).
+ * Only the name tab (`.frame-drag-handle`) drags, so pointer events inside
+ * the iframe stay with the customer's document.
+ */
+export type FrameDescriptor = {
+ /** Display name on the frame's drag-handle tab. */
+ name: string;
+ /** Registry id of the discovered component this frame renders. */
+ componentId: string;
+ /** The adapter's origin — pinned targetOrigin for the canvas protocol. */
+ adapterOrigin: string;
+ /** Viewport size of the frame; defaults to 800x600. */
+ width?: number;
+ height?: number;
+};
+
+export type FrameNodeData = {
+ frame: FrameDescriptor;
+};
+
+export const DEFAULT_FRAME_WIDTH = 800;
+export const DEFAULT_FRAME_HEIGHT = 600;
+
+export function FrameNode({ data }: { data: FrameNodeData }) {
+ const { frame } = data;
+ const width = frame.width ?? DEFAULT_FRAME_WIDTH;
+ const height = frame.height ?? DEFAULT_FRAME_HEIGHT;
+
+ const iframeRef = useRef(null);
+ const [ready, setReady] = useState(false);
+ const [targets, setTargets] = useState([]);
+ const selectedNodeId = useEditorStore((s) => s.selectedNodeId);
+ const select = useEditorStore((s) => s.select);
+
+ useEffect(() => {
+ const client = createCanvasClient({
+ adapterOrigin: frame.adapterOrigin,
+ getAgentWindow: () => iframeRef.current?.contentWindow ?? null,
+ handlers: {
+ onRuntimeReady: () => {
+ setReady(true);
+ client.renderFrame(frame.componentId);
+ client.requestGeometry();
+ },
+ onGeometry: setTargets,
+ onClick: select,
+ },
+ });
+ client.start();
+ return () => client.stop();
+ }, [frame.adapterOrigin, frame.componentId, select]);
+
+ const selectedRect =
+ targets.find((target) => target.nodeId === selectedNodeId)?.rect ?? null;
+
+ return (
+
+
+ {frame.name}{" "}
+
+ {frame.componentId} · {width} × {height}
+
+
+
+
+ {!ready && (
+
+ Starting preview…
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/editor/src/ui/components/react-flow.mock.ts b/packages/editor/src/ui/components/react-flow.mock.ts
new file mode 100644
index 0000000..03f4ac8
--- /dev/null
+++ b/packages/editor/src/ui/components/react-flow.mock.ts
@@ -0,0 +1,86 @@
+/**
+ * jsdom shims React Flow needs to render in component tests, adapted from
+ * the official React Flow testing guide
+ * (https://reactflow.dev/learn/advanced-use/testing). jsdom implements
+ * neither `ResizeObserver` nor `DOMMatrixReadOnly`, and its elements have
+ * no layout, so `offsetWidth`/`offsetHeight` fall back to inline styles.
+ *
+ * Call `mockReactFlow()` once per test file (module scope or `beforeAll`)
+ * before rendering a board. Idempotent.
+ */
+let initialized = false;
+
+export function mockReactFlow(): void {
+ if (initialized) return;
+ initialized = true;
+
+ class ResizeObserverMock {
+ private readonly callback: ResizeObserverCallback;
+
+ constructor(callback: ResizeObserverCallback) {
+ this.callback = callback;
+ }
+
+ observe(target: Element): void {
+ const width = (target as HTMLElement).offsetWidth || 1;
+ const height = (target as HTMLElement).offsetHeight || 1;
+ const contentRect = {
+ x: 0,
+ y: 0,
+ top: 0,
+ left: 0,
+ right: width,
+ bottom: height,
+ width,
+ height,
+ toJSON: () => ({ width, height }),
+ } as DOMRectReadOnly;
+ this.callback(
+ [{ target, contentRect } as ResizeObserverEntry],
+ this as unknown as ResizeObserver,
+ );
+ }
+
+ unobserve(): void {
+ // jsdom shim — nothing to release.
+ }
+
+ disconnect(): void {
+ // jsdom shim — nothing to release.
+ }
+ }
+ globalThis.ResizeObserver =
+ ResizeObserverMock as unknown as typeof ResizeObserver;
+
+ class DOMMatrixReadOnlyMock {
+ readonly m22: number;
+
+ constructor(transform?: string) {
+ const scale = transform?.match(/scale\(([\d.]+)\)/)?.[1];
+ this.m22 = scale === undefined ? 1 : Number(scale);
+ }
+ }
+ globalThis.DOMMatrixReadOnly =
+ DOMMatrixReadOnlyMock as unknown as typeof DOMMatrixReadOnly;
+
+ Object.defineProperties(globalThis.HTMLElement.prototype, {
+ offsetHeight: {
+ configurable: true,
+ get(this: HTMLElement) {
+ return Number.parseFloat(this.style.height) || 1;
+ },
+ },
+ offsetWidth: {
+ configurable: true,
+ get(this: HTMLElement) {
+ return Number.parseFloat(this.style.width) || 1;
+ },
+ },
+ });
+
+ (
+ globalThis.SVGElement.prototype as SVGElement & {
+ getBBox: () => DOMRect;
+ }
+ ).getBBox = () => ({ x: 0, y: 0, width: 0, height: 0 }) as DOMRect;
+}
diff --git a/packages/editor/src/ui/components/selection-overlay.stories.tsx b/packages/editor/src/ui/components/selection-overlay.stories.tsx
new file mode 100644
index 0000000..38f02e1
--- /dev/null
+++ b/packages/editor/src/ui/components/selection-overlay.stories.tsx
@@ -0,0 +1,44 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { SelectionOverlay } from "./selection-overlay";
+
+/**
+ * Editor-side selection chrome (ADR-028): the ring is positioned from
+ * agent-reported geometry and never renders inside the customer's iframe
+ * document. The gray box below stands in for a frame's iframe viewport.
+ */
+const meta = {
+ title: "Editor/SelectionOverlay",
+ component: SelectionOverlay,
+ tags: ["autodocs"],
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+} satisfies Meta;
+export default meta;
+
+type Story = StoryObj;
+
+export const Selected: Story = {
+ args: { rect: { x: 48, y: 64, width: 240, height: 56 } },
+};
+
+export const FullBleedElement: Story = {
+ args: { rect: { x: 0, y: 0, width: 480, height: 240 } },
+};
+
+export const NoSelection: Story = {
+ args: { rect: null },
+};
diff --git a/packages/editor/src/ui/components/selection-overlay.test.tsx b/packages/editor/src/ui/components/selection-overlay.test.tsx
new file mode 100644
index 0000000..2531a04
--- /dev/null
+++ b/packages/editor/src/ui/components/selection-overlay.test.tsx
@@ -0,0 +1,31 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { SelectionOverlay } from "./selection-overlay";
+
+describe("SelectionOverlay", () => {
+ it("renders nothing when rect is null (no selection)", () => {
+ render();
+ expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
+ });
+
+ it("positions the ring from the agent-reported rect", () => {
+ render(
+ ,
+ );
+ const overlay = screen.getByTestId("selection-overlay");
+ expect(overlay).toHaveStyle({
+ position: "absolute",
+ left: "24px",
+ top: "12px",
+ width: "320px",
+ height: "48px",
+ });
+ });
+
+ it("is chrome, not content: hidden from a11y and inert to pointers (ADR-028)", () => {
+ render();
+ const overlay = screen.getByTestId("selection-overlay");
+ expect(overlay).toHaveAttribute("aria-hidden", "true");
+ expect(overlay).toHaveStyle({ pointerEvents: "none" });
+ });
+});
diff --git a/packages/editor/src/ui/components/selection-overlay.tsx b/packages/editor/src/ui/components/selection-overlay.tsx
new file mode 100644
index 0000000..7aeea44
--- /dev/null
+++ b/packages/editor/src/ui/components/selection-overlay.tsx
@@ -0,0 +1,36 @@
+import type { Rect } from "../../canvas-protocol/messages";
+
+/**
+ * Editor-side selection ring, positioned from agent-reported geometry
+ * (ADR-028): chrome never renders inside the customer's iframe document.
+ * The overlay lives in the frame node's coordinate space (the iframe's
+ * top-left is the origin), so React Flow's pan/zoom transform carries it
+ * along with the iframe for free — no protocol traffic on drag.
+ */
+export type SelectionOverlayProps = {
+ /** Bounding rect of the selected node, in iframe CSS pixels; `null` hides the ring. */
+ rect: Rect | null;
+};
+
+export function SelectionOverlay({ rect }: SelectionOverlayProps) {
+ if (rect === null) return null;
+ return (
+
+ );
+}
diff --git a/packages/editor/src/ui/index.ts b/packages/editor/src/ui/index.ts
index 995d33f..69dc8e4 100644
--- a/packages/editor/src/ui/index.ts
+++ b/packages/editor/src/ui/index.ts
@@ -1,5 +1,15 @@
// 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.
+// `@repo/editor/ui` and contracts from `@repo/editor`.
export { useEditorStore, type EditorStoreState } from "../store/editor-store";
+export { EditorBoard, type EditorBoardProps } from "./components/editor-board";
+export {
+ FrameNode,
+ type FrameNodeData,
+ type FrameDescriptor,
+ DEFAULT_FRAME_WIDTH,
+ DEFAULT_FRAME_HEIGHT,
+} from "./components/frame-node";
+export {
+ SelectionOverlay,
+ type SelectionOverlayProps,
+} from "./components/selection-overlay";