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:
2026-07-12 22:18:24 +02:00
parent c2f56ac8c9
commit 9495023aed
13 changed files with 899 additions and 4 deletions

View File

@@ -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",

View File

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

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

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

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

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

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

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

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

View File

@@ -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 },
};

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

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

View File

@@ -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";