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} + +
+
+