feat(editor): board shell with iframe frame and selection overlay
React Flow board (pan/zoom + frame drag are pure editor-side transforms — zero canvas-protocol traffic, <16ms budget) hosting one iframe frame node on the adapter origin. The frame waits for runtime.ready (queue-drain) behind an explicit 'Starting preview…' skeleton, then requests a single Element via render-frame (core-runner-protocol schema) plus geometry; agent-reported geometry stays frame-local and click reports drive store selection. The selection ring renders as an editor-side overlay from reported geometry — chrome never inside the customer's document (ADR-028). Stories + jsdom component tests for board/frame/overlay; Storybook stories glob extended to packages/editor (verified via static build). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
@@ -2,7 +2,10 @@ import type { StorybookConfig } from "@storybook/react-vite";
|
|||||||
|
|
||||||
const config: StorybookConfig = {
|
const config: StorybookConfig = {
|
||||||
framework: "@storybook/react-vite",
|
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"],
|
addons: ["@storybook/addon-essentials"],
|
||||||
docs: {
|
docs: {
|
||||||
autodocs: "tag",
|
autodocs: "tag",
|
||||||
|
|||||||
@@ -19,6 +19,25 @@ export type PostedMessage = {
|
|||||||
targetOrigin: string;
|
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 = {
|
type PostOverrides = {
|
||||||
/** Origin the event claims to come from. Defaults to the double's origin. */
|
/** Origin the event claims to come from. Defaults to the double's origin. */
|
||||||
origin?: string;
|
origin?: string;
|
||||||
|
|||||||
49
packages/editor/src/ui/components/editor-board.stories.tsx
Normal file
49
packages/editor/src/ui/components/editor-board.stories.tsx
Normal file
@@ -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) => (
|
||||||
|
<div style={{ width: "100%", height: 600 }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof EditorBoard>;
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const WithFrame: Story = {
|
||||||
|
decorators: [
|
||||||
|
(Story) => {
|
||||||
|
useEditorStore.setState({
|
||||||
|
registry: [{ id: "button", name: "Button" }],
|
||||||
|
selectedNodeId: null,
|
||||||
|
});
|
||||||
|
return <Story />;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmptyRegistry: Story = {
|
||||||
|
decorators: [
|
||||||
|
(Story) => {
|
||||||
|
useEditorStore.setState({ registry: [], selectedNodeId: null });
|
||||||
|
return <Story />;
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
115
packages/editor/src/ui/components/editor-board.test.tsx
Normal file
115
packages/editor/src/ui/components/editor-board.test.tsx
Normal file
@@ -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(
|
||||||
|
<div style={{ width: "1200px", height: "800px" }}>
|
||||||
|
<EditorBoard adapterOrigin={ADAPTER_ORIGIN} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
101
packages/editor/src/ui/components/editor-board.tsx
Normal file
101
packages/editor/src/ui/components/editor-board.tsx
Normal file
@@ -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<FrameNodeData, "frame">;
|
||||||
|
|
||||||
|
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<BoardNode[]>(
|
||||||
|
() =>
|
||||||
|
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<BoardNode[]>(initialNodes);
|
||||||
|
|
||||||
|
// Resync when the registry (or adapter origin) changes.
|
||||||
|
useEffect(() => setNodes(initialNodes), [initialNodes]);
|
||||||
|
|
||||||
|
const onNodesChange = useCallback(
|
||||||
|
(changes: NodeChange<BoardNode>[]) =>
|
||||||
|
setNodes((current) => applyNodeChanges(changes, current)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-testid="editor-board"
|
||||||
|
style={{ position: "relative", width: "100%", height: "100%" }}
|
||||||
|
>
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={[]}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onPaneClick={() => 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}
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={26} size={1} />
|
||||||
|
</ReactFlow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
packages/editor/src/ui/components/frame-node.stories.tsx
Normal file
62
packages/editor/src/ui/components/frame-node.stories.tsx
Normal file
@@ -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<typeof FrameNode>;
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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" });
|
||||||
|
},
|
||||||
|
};
|
||||||
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal file
197
packages/editor/src/ui/components/frame-node.test.tsx
Normal file
@@ -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<typeof postAgentEnvelopeToWindow>[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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
expect(screen.getByText("Starting preview…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing before runtime.ready — queue-drain semantics", () => {
|
||||||
|
render(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={data} />);
|
||||||
|
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(<FrameNode data={{ frame: { ...data.frame } }} />);
|
||||||
|
rerender(<FrameNode data={{ frame: { ...data.frame } }} />);
|
||||||
|
|
||||||
|
expect(post).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops listening on unmount — late agent messages no longer reach the store", () => {
|
||||||
|
const { unmount } = render(<FrameNode data={data} />);
|
||||||
|
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(
|
||||||
|
<FrameNode
|
||||||
|
data={{
|
||||||
|
frame: {
|
||||||
|
name: "Card",
|
||||||
|
componentId: "card",
|
||||||
|
adapterOrigin: ADAPTER_ORIGIN,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/800 × 600/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
142
packages/editor/src/ui/components/frame-node.tsx
Normal file
142
packages/editor/src/ui/components/frame-node.tsx
Normal file
@@ -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<HTMLIFrameElement | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [targets, setTargets] = useState<readonly GeometryTarget[]>([]);
|
||||||
|
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 (
|
||||||
|
<div style={{ width }}>
|
||||||
|
<div
|
||||||
|
className="frame-drag-handle"
|
||||||
|
title="Drag to move"
|
||||||
|
style={{
|
||||||
|
cursor: "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
fontFamily: "ui-monospace, monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "2px 0 6px",
|
||||||
|
color: "var(--veect-text-muted, #6b7280)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{frame.name}{" "}
|
||||||
|
<span style={{ fontSize: 10.5, opacity: 0.7 }}>
|
||||||
|
{frame.componentId} · {width} × {height}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
background: "var(--veect-frame-bg, #ffffff)",
|
||||||
|
border: "1px solid var(--veect-line, #e5e7eb)",
|
||||||
|
borderRadius: 4,
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
src={frame.adapterOrigin}
|
||||||
|
title={`${frame.name} frame`}
|
||||||
|
sandbox="allow-scripts allow-same-origin"
|
||||||
|
style={{ width: "100%", height: "100%", border: 0, display: "block" }}
|
||||||
|
/>
|
||||||
|
{!ready && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: "var(--veect-frame-bg, #ffffff)",
|
||||||
|
color: "var(--veect-text-muted, #6b7280)",
|
||||||
|
fontFamily: "ui-monospace, monospace",
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Starting preview…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<SelectionOverlay rect={selectedRect} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
packages/editor/src/ui/components/react-flow.mock.ts
Normal file
86
packages/editor/src/ui/components/react-flow.mock.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: 480,
|
||||||
|
height: 240,
|
||||||
|
background: "#f3f4f6",
|
||||||
|
border: "1px solid #e5e7eb",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof SelectionOverlay>;
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
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 },
|
||||||
|
};
|
||||||
31
packages/editor/src/ui/components/selection-overlay.test.tsx
Normal file
31
packages/editor/src/ui/components/selection-overlay.test.tsx
Normal file
@@ -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(<SelectionOverlay rect={null} />);
|
||||||
|
expect(screen.queryByTestId("selection-overlay")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("positions the ring from the agent-reported rect", () => {
|
||||||
|
render(
|
||||||
|
<SelectionOverlay rect={{ x: 24, y: 12, width: 320, height: 48 }} />,
|
||||||
|
);
|
||||||
|
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(<SelectionOverlay rect={{ x: 0, y: 0, width: 10, height: 10 }} />);
|
||||||
|
const overlay = screen.getByTestId("selection-overlay");
|
||||||
|
expect(overlay).toHaveAttribute("aria-hidden", "true");
|
||||||
|
expect(overlay).toHaveStyle({ pointerEvents: "none" });
|
||||||
|
});
|
||||||
|
});
|
||||||
36
packages/editor/src/ui/components/selection-overlay.tsx
Normal file
36
packages/editor/src/ui/components/selection-overlay.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
data-testid="selection-overlay"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: rect.x,
|
||||||
|
top: rect.y,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
pointerEvents: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
border: "1.5px solid var(--veect-accent, #4f46e5)",
|
||||||
|
borderRadius: 2,
|
||||||
|
boxShadow:
|
||||||
|
"0 0 0 3px var(--veect-accent-soft, rgba(79, 70, 229, 0.18))",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
// Public UI surface of @repo/editor: apps import components/hooks from
|
// Public UI surface of @repo/editor: apps import components/hooks from
|
||||||
// `@repo/editor/ui` and contracts from `@repo/editor`. The React Flow
|
// `@repo/editor/ui` and contracts from `@repo/editor`.
|
||||||
// board, iframe frame node, and selection overlay land here with the
|
|
||||||
// board-shell slice.
|
|
||||||
export { useEditorStore, type EditorStoreState } from "../store/editor-store";
|
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";
|
||||||
|
|||||||
Reference in New Issue
Block a user